Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ac5f672e | ||
|
|
cc418e0b08 |
No files matched your search
@@ -1,15 +0,0 @@
|
||||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
#
|
||||
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
|
||||
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
|
||||
# this group caps them at 4.
|
||||
[test-groups]
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
test-group = 'gpu'
|
||||
@@ -1,18 +0,0 @@
|
||||
.git
|
||||
.github
|
||||
.agents
|
||||
.codex
|
||||
.aws
|
||||
**/target
|
||||
dist
|
||||
corpus
|
||||
log
|
||||
plan
|
||||
secrets
|
||||
temp
|
||||
craft-fonts
|
||||
book/output
|
||||
**/.DS_Store
|
||||
**/.env
|
||||
**/.env.*
|
||||
*.log
|
||||
@@ -1,4 +0,0 @@
|
||||
# Custom runner labels actionlint can't know about.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- macos-release # org larger runner (M2 Pro), used by release.yml
|
||||
@@ -17,83 +17,25 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
|
||||
# bypasses that, lp fails loudly on a printer that does not exist.
|
||||
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).
|
||||
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
|
||||
# that lands while main's run is in progress waits for it, so every main state that gets a run is
|
||||
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
|
||||
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
# macOS is tested only by release.yml.
|
||||
platforms:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
||||
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"]'
|
||||
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
|
||||
# packages changed: the web app and its packaging, crate sources and manifests (the web
|
||||
# build compiles nearly every crate), the workspace manifests and this workflow.
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
|
||||
# background: the space is free before the craft-fonts build needs it, and nothing waits.
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/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 \
|
||||
@@ -103,92 +45,21 @@ jobs:
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# 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.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked --features heif
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
corpus:
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
|
||||
name: corpus tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# test-corpus builds with --release, but here without the release profile's thin LTO and
|
||||
# single codegen unit: they ran LTO over the dependency graph for each of the ~95 corpus test
|
||||
# binaries, so building took 23.5 of the job's 37 minutes while the tests ran for 1.5.
|
||||
# Turning them off builds 2.7x faster; the code under test is the same.
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: corpus
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Restore test corpora
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: corpus/
|
||||
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
||||
- name: Fetch and verify corpora
|
||||
run: cargo xtask corpus --all
|
||||
- name: Corpus tests
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -200,55 +71,9 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
name: web build and wasm size gate
|
||||
# PRs run it only when web-relevant files change (see `platforms`); main always does.
|
||||
needs: platforms
|
||||
if: needs.platforms.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Match release.yml so PR results reflect what would actually ship.
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ci-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
|
||||
# wasm limit and exercises the same packaging step used by release.yml.
|
||||
- name: Build and verify browser release
|
||||
run: packaging/web/package.sh
|
||||
- name: Report wasm size
|
||||
if: success()
|
||||
run: |
|
||||
{
|
||||
echo "### Browser WebAssembly size"
|
||||
echo
|
||||
echo "| File | Bytes | MiB | Limit |"
|
||||
echo "| --- | ---: | ---: | ---: |"
|
||||
for wasm in dist/web/*.wasm; do
|
||||
[ -f "$wasm" ] || continue
|
||||
bytes=$(wc -c < "$wasm" | tr -d ' ')
|
||||
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
|
||||
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fuzz:
|
||||
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
||||
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
||||
@@ -260,23 +85,11 @@ jobs:
|
||||
target:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: psd, name: grd }
|
||||
- { crate: psd, name: ase }
|
||||
- { crate: psd, name: aco }
|
||||
- { crate: psd, name: patterns }
|
||||
- { crate: psd, name: filter_effects }
|
||||
- { crate: psd, name: metadata }
|
||||
- { crate: psd, name: tagged }
|
||||
- { crate: psd, name: resources }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
- { crate: codecs, name: decode_tiff }
|
||||
- { crate: codecs, name: decode_exr }
|
||||
- { crate: codecs, name: decode_heif }
|
||||
- { crate: affinity, name: preview }
|
||||
- { crate: affinity, name: import_preview, args: "--features io" }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -288,7 +101,7 @@ jobs:
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Fuzz
|
||||
working-directory: crates/${{ matrix.target.crate }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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$'
|
||||
@@ -19,12 +19,44 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
verified-appimage-inputs:
|
||||
name: Verified AppImage inputs
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash packaging/linux/verified-download-test.sh
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
@@ -1,91 +0,0 @@
|
||||
name: Perf (nightly)
|
||||
|
||||
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
|
||||
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
|
||||
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
|
||||
# more than 15 % against perf/baseline.json from the same machine class (other classes are
|
||||
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
|
||||
#
|
||||
# To give this runner class its own baseline: run it by hand with "update_baseline", download
|
||||
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quick:
|
||||
description: "Quick subset (small documents, no budgets)"
|
||||
type: boolean
|
||||
default: false
|
||||
update_baseline:
|
||||
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
perf:
|
||||
name: perf (macOS, Apple GPU)
|
||||
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
- name: Run benchmarks
|
||||
id: perf
|
||||
env:
|
||||
QUICK: ${{ inputs.quick }}
|
||||
UPDATE: ${{ inputs.update_baseline }}
|
||||
run: |
|
||||
args=()
|
||||
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
|
||||
cargo xtask perf "${args[@]}"
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f target/perf/summary.md ]; then
|
||||
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-results
|
||||
path: |
|
||||
target/perf/*.json
|
||||
target/perf/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload baseline
|
||||
if: always() && inputs.update_baseline
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-baseline
|
||||
path: perf/baseline.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
@@ -1,3 +1,9 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "PhotoCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
|
||||
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
|
||||
# warning, never a failed build.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -11,33 +17,307 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### PhotoCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/photocraft$|metainfo|applications'
|
||||
ldd target/release/photocraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: photocraft
|
||||
app-title: PhotoCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="PhotoCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,31 +0,0 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: photocraft
|
||||
app-dir: PhotoCraft
|
||||
@@ -8,6 +8,3 @@ target/
|
||||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
/craft-fonts
|
||||
*.log
|
||||
__pycache__/
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md: guide for AI agents and contributors
|
||||
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there; shared Rust components live in [storytold/craft-libs](https://github.com/storytold/craft-libs) and are pinned by immutable git revision. UI libraries stay at L6. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
|
||||
|
||||
## 1. Orientation (5 minutes)
|
||||
|
||||
@@ -11,15 +11,9 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
|
||||
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
|
||||
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
|
||||
| `ROADMAP.md` | One-page summary: stage, the two parity numbers, every dimension, languages, what's next |
|
||||
| `docs/target-app-parity.md` | The authoritative parity assessment against Photoshop 2026: numbers, weights, evidence, hours |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list to pick from |
|
||||
| `docs/roadmap.md` | Milestones, **current focus**, what's next with estimates |
|
||||
| `docs/ui-parity.md`, `docs/brush-parity.md`, `docs/context-menu-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` | Per-dimension parity checklists |
|
||||
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
|
||||
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
||||
## 2. Workspace map
|
||||
|
||||
@@ -27,7 +21,6 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -39,7 +32,7 @@ apps/
|
||||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
|
||||
```
|
||||
|
||||
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
|
||||
@@ -51,7 +44,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
@@ -61,7 +54,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
|
||||
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
|
||||
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
|
||||
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
|
||||
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
|
||||
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
|
||||
@@ -74,17 +67,13 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
|
||||
|
||||
0. **Read `ROADMAP.md`, then `docs/gaps.md`.** `docs/target-app-parity.md` says, dimension by
|
||||
dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap,
|
||||
update the parity doc and gap entry it belongs to, with the date, and bump their status line
|
||||
and revision history (craftrules `standards/progress-docs.md`).
|
||||
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
|
||||
the performance scenarios that are over budget or not measurable yet, and the count of
|
||||
settings that do nothing. Its numbers are measured; prefer them to estimates.
|
||||
2. `docs/roadmap.md` → **Current focus**.
|
||||
3. `cargo xtask parity` → `docs/parity-checklist.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
4. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
|
||||
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
|
||||
round trips, workflow tests, performance), update that section with the dated figure.
|
||||
1. `docs/roadmap.md` → **Current focus**.
|
||||
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
3. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
|
||||
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
|
||||
|
||||
@@ -95,19 +84,10 @@ cargo test -p <crates you touched>
|
||||
cargo clippy -p <crates> --all-targets -- -D warnings
|
||||
cargo xtask layers
|
||||
cargo xtask wasm # if you touched L0–L6
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
|
||||
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
|
||||
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
|
||||
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
|
||||
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
|
||||
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
|
||||
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
|
||||
```
|
||||
|
||||
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
||||
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
|
||||
@@ -124,13 +104,12 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
|
||||
|
||||
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
|
||||
- `ROADMAP.md`, `docs/target-app-parity.md`, `docs/gaps.md` and the `docs/*-parity.md` checklists: where we stand against Photoshop.
|
||||
- `docs/parity-checklist.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `docs/parity.md`: generated Photoshop menu coverage.
|
||||
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
- `log/` (local, gitignored): the dev log.
|
||||
-
|
||||
|
||||
## 8. Keeping the native format complete
|
||||
|
||||
@@ -138,21 +117,3 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
nothing is silently dropped from `.pcraft` saves. When you add a doc field, add it to
|
||||
`crates/format/src/manifest.rs` and `convert.rs` with `#[serde(default)]` so older files still load.
|
||||
If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown PSD blocks verbatim.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
@@ -10,66 +10,15 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.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-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,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 | <https://github.com/feathericons/feather> 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 |
|
||||
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `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/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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-add-anchor.svg`, `assets/icons/pen-delete-anchor.svg` | Add Anchor Point and Delete Anchor Point tool glyphs: Lucide `pen-tool` plus a + or − mark | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
|
||||
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
|
||||
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
|
||||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
|
||||
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | 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, <https://invent.kde.org/plasma/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 |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
|
||||
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
|
||||
from it is committed here. Per-file authors, sources and licences:
|
||||
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
| Font (embedded at build time) | Use | License |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
|
||||
|
||||
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
|
||||
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
|
||||
@@ -81,7 +30,6 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
|---|---|---|---|---|
|
||||
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
|
||||
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
|
||||
|
||||
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
|
||||
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
|
||||
@@ -100,20 +48,6 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
|
||||
## Test data (not committed, not shipped)
|
||||
|
||||
`corpus/` is gitignored and no test fixtures are committed to the repository.
|
||||
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
|
||||
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
|
||||
next to the files:
|
||||
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
|
||||
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
|
||||
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
|
||||
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
|
||||
@@ -1,16 +1,15 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
photocraft-geom = { path = "crates/geom" }
|
||||
photocraft-color = { path = "crates/color" }
|
||||
@@ -21,7 +20,6 @@ photocraft-ops = { path = "crates/ops" }
|
||||
photocraft-psd = { path = "crates/psd" }
|
||||
photocraft-codecs = { path = "crates/codecs" }
|
||||
photocraft-raw = { path = "crates/raw" }
|
||||
photocraft-affinity = { path = "crates/affinity" }
|
||||
photocraft-compose = { path = "crates/compose" }
|
||||
photocraft-gpu = { path = "crates/gpu" }
|
||||
photocraft-paint = { path = "crates/paint" }
|
||||
@@ -35,10 +33,8 @@ photocraft-plugins = { path = "crates/plugins" }
|
||||
photocraft-engine = { path = "crates/engine" }
|
||||
photocraft-ui-egui = { path = "crates/ui-egui" }
|
||||
photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
@@ -46,14 +42,9 @@ arc-swap = "1"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
image = { version = "0.25", default-features = false }
|
||||
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
|
||||
zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
|
||||
# (egui_fonts.rs; the web build's size gate).
|
||||
egui = { version = "0.36", default-features = false }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
log = "0.4"
|
||||
@@ -73,71 +64,7 @@ opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
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.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
|
||||
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
|
||||
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
|
||||
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
|
||||
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
# Decoding and rasterizing keep their speed.
|
||||
[profile.wasm-release.package.photocraft-codecs]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-raw]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-heif]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-vector]
|
||||
opt-level = "s"
|
||||
|
||||
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
|
||||
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
|
||||
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
|
||||
[profile.wasm-release.package.photocraft-doc]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-psd]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-format]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-affinity]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.serde_json]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.usvg]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official
|
||||
# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh).
|
||||
#
|
||||
# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust
|
||||
# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI
|
||||
# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the
|
||||
# workspace `rust-version` in Cargo.toml.
|
||||
ARG RUST_VERSION=1.99
|
||||
ARG NGINX_VERSION=1.28-alpine
|
||||
|
||||
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
|
||||
# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml.
|
||||
ARG TRUNK_VERSION=0.21.14
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup target add wasm32-unknown-unknown
|
||||
RUN set -eu; \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) arch=x86_64 ;; \
|
||||
aarch64) arch=aarch64 ;; \
|
||||
*) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \
|
||||
esac; \
|
||||
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
|
||||
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
|
||||
cd /tmp; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
|
||||
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
|
||||
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
|
||||
tar -xzf "$asset" -C /usr/local/bin trunk; \
|
||||
rm "$asset" "$asset.sha256"; \
|
||||
trunk --version
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
WORKDIR /src/apps/photocraft-web
|
||||
# Docker serving has no static-host per-file size cap. Thin LTO uses less build
|
||||
# memory than the release zip's fat LTO; retain the profile's other optimizations.
|
||||
ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin
|
||||
ARG CARGO_BUILD_JOBS=1
|
||||
# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs).
|
||||
# .git is not in the build context, so pass it in, e.g.
|
||||
# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)
|
||||
# Left empty, the build reports itself as a dev build.
|
||||
ARG PHOTOCRAFT_BUILD_SHA=
|
||||
ARG PHOTOCRAFT_BUILD_DATE=
|
||||
# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/src/target \
|
||||
--mount=type=cache,target=/root/.cache/trunk \
|
||||
trunk build --release --locked \
|
||||
&& test -s /src/dist/web/index.html \
|
||||
&& gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html
|
||||
|
||||
FROM nginx:${NGINX_VERSION} AS runtime
|
||||
COPY packaging/web/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=builder /src/dist/web/ /usr/share/nginx/html/
|
||||
COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/
|
||||
COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \
|
||||
crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/
|
||||
# Bypass the stock entrypoint: this configuration needs no runtime rewriting.
|
||||
USER nginx
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
|
||||
@@ -16,8 +16,3 @@ 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.
|
||||
|
||||
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
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
</p>
|
||||
@@ -52,7 +52,6 @@
|
||||
<a href="#built-for-agents">Agents</a> ·
|
||||
<a href="#under-the-hood">Under the hood</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a> ·
|
||||
<a href="https://discord.gg/artcraft">Discord</a>
|
||||
</p>
|
||||
@@ -71,7 +70,7 @@
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🗂️ Real PSD files</h3>
|
||||
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
|
||||
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
@@ -122,7 +121,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<br>
|
||||
<sub>A headline edited in place, with a byline and a paragraph of body text.<br><i>Among the Sierra Nevada, California</i>, Albert Bierstadt, 1868</sub>
|
||||
<h3>Type that sets beautifully</h3>
|
||||
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour. The <a href="docs/color-picker.md">full Type Color Picker</a> also supports screen sampling with a pixel loupe.
|
||||
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour.
|
||||
<br><br>
|
||||
Type layers stay editable, take layer styles, and round-trip through PSD.
|
||||
</td>
|
||||
@@ -133,7 +132,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<br>
|
||||
<sub>A badge made of shape layers: a gradient-filled lotus, a star and a dotted ring.<br><i>Water Lilies</i>, Claude Monet, 1906</sub>
|
||||
<h3>Pixel-perfect vectors</h3>
|
||||
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen, Freeform Pen and Curvature Pen tools, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
|
||||
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen tool, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
|
||||
<br><br>
|
||||
Combine shapes (unite, subtract, intersect, exclude), keep paths in the Paths panel, use them as vector masks, or stroke and fill them. 116 of 116 shape layers in our PSD corpus match Photoshop's pixels.
|
||||
</td>
|
||||
@@ -178,8 +177,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 67 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 34 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -187,7 +186,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗃️ Layers, done properly</h4>
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 blend modes, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -199,11 +198,11 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘F).
|
||||
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘K).
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -214,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
@@ -232,9 +231,6 @@ photocraft-cli run wave.psd \
|
||||
# Apply one action list to a folder of images
|
||||
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
|
||||
|
||||
# Every subcommand explains itself
|
||||
photocraft-cli batch --help
|
||||
|
||||
# Let an agent drive it over MCP (headless, or bridged to the running app)
|
||||
photocraft-cli mcp
|
||||
```
|
||||
@@ -259,111 +255,28 @@ cargo run --release -p photocraft -- image.psd # the desktop app
|
||||
cargo test --workspace # the test suite
|
||||
```
|
||||
|
||||
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
|
||||
```
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
||||
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
|
||||
```sh
|
||||
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
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-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
```sh
|
||||
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
|
||||
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
|
||||
# ... accepted, source=Notarized Developer ID
|
||||
```
|
||||
|
||||
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
|
||||
|
||||
```sh
|
||||
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
|
||||
photocraft
|
||||
```
|
||||
|
||||
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!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).
|
||||
> **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, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). 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).
|
||||
|
||||
## Documentation
|
||||
|
||||
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
|
||||
|
||||
To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker).
|
||||
|
||||
## Security
|
||||
|
||||
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
|
||||
|
||||
## Test corpora
|
||||
|
||||
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
|
||||
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
|
||||
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
|
||||
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `photocraft-<ver>-windows-x64.msi` | `photocraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `photocraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `photocraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `photocraft-<ver>-linux-x86_64.AppImage` | `photocraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `photocraft-<ver>-linux-x86_64.flatpak` | `photocraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `photocraft-<ver>-linux-x86_64.deb` | `photocraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `photocraft-<ver>-linux-x86_64.rpm` | `photocraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `photocraft-<ver>-linux-x86_64.tar.gz` | `photocraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `photocraft-<ver>-linux-riscv64.tar.gz` only, built against Ubuntu 24.04 (needs glibc 2.39+).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `photocraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `photocraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
@@ -376,7 +289,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -430,6 +343,5 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
|
||||
ArtCraft
|
||||
@@ -1,143 +0,0 @@
|
||||
# PhotoCraft roadmap
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
(methodology, weights, evidence), [gaps](docs/gaps.md) (the ranked work list),
|
||||
[roadmap](docs/roadmap.md) (milestones, current focus), [scorecard](docs/scorecard.md) and
|
||||
[menu checklist](docs/parity-checklist.md) (both generated and measured),
|
||||
[architecture](docs/architecture.md).
|
||||
|
||||
Hours are Opus 5.5 agent wall-clock hours for one agent working sequentially, calibrated from this
|
||||
repository's 1,004 merged PRs (see [calibration](docs/target-app-parity.md#remaining-effort-and-calibration)).
|
||||
About 80% of the work parallelizes.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 56/68, panels 30/35, formats 13+4 of 31 |
|
||||
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
|
||||
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
|
||||
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
|
||||
| Remaining to **beta** | **~1,100–1,800 h** | estimated |
|
||||
| Remaining to **full parity** | **~2,300–4,100 h** | estimated; AI and plug-ins need owner decisions |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent-hours to ~95% | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | ~45% (additive weighted sum: 46%) | 2,300–4,100 | Everything below, plus AI tier, plug-in/scripting ecosystem, video, localization, hardware |
|
||||
| Mainstream practitioner | ~43% | 1,700–2,800 | Depth in the 11 weekly areas (1,180–2,000), performance budgets, stability on real machines, PSD exchange with Photoshop |
|
||||
| Essentials user | ~61% | 400–700 | Startup and GPU robustness, brush and transform responsiveness, Type tool, discoverability (flyouts, commit buttons), AVIF/PDF open |
|
||||
|
||||
Hours calibrated as in [Remaining effort](docs/target-app-parity.md#remaining-effort-and-calibration) (2–9 h per checklist
|
||||
item, 8–20 h per format or subsystem, from the repo's 1,004 merged PRs); each tier is a subset of
|
||||
the one above. About 80% parallelizes (independent crates and command modules); performance
|
||||
architecture and the PSD writer are the serial parts.
|
||||
|
||||
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
|
||||
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
|
||||
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
|
||||
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
|
||||
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
|
||||
four are partial in depth, none blocked.
|
||||
|
||||
## By dimension
|
||||
|
||||
| Dimension | % ready | Hours to full | Doc |
|
||||
|---|---:|---:|---|
|
||||
| Features (depth) | 55% (breadth 76%) | 600–1,000 | [target-app-parity](docs/target-app-parity.md#feature-areas) |
|
||||
| UI/UX fidelity (tools, handles, modifiers, brush feel, panels) | 40% | 350–600 | [ui-parity](docs/ui-parity.md), [brush-parity](docs/brush-parity.md), [context-menu-parity](docs/context-menu-parity.md) |
|
||||
| File formats | 60% (presence ~80%) | 300–500 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, displays) | 50% | 80–150 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 55% | 80–140 | [localization-parity](docs/localization-parity.md) |
|
||||
| Performance | 25% (3/25 budgets met, measured) | 200–350 | [scorecard](docs/scorecard.md#performance) |
|
||||
| Stability | 45% | 150–250 | [gaps](docs/gaps.md#g3-user-reported-bug-backlog) |
|
||||
| Platforms | 80% | 30–60 | [scorecard](docs/scorecard.md#distribution) |
|
||||
| Ecosystem / plug-ins / scripting | 15% | 250–450 | [gaps](docs/gaps.md#g16-plug-in-compatibility) |
|
||||
| AI features | 10% | 300–600 | [gaps](docs/gaps.md#g15-ai-and-generative-features) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | % ready | Hours |
|
||||
|---|---:|---:|
|
||||
| Layers, masks, blend modes, smart objects, styles | 65% | 150–250 |
|
||||
| Selections, Select and Mask, channels | 55% | 120–200 |
|
||||
| Painting and brush engine | 55% | 150–250 |
|
||||
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 |
|
||||
| Transform, warp, puppet, liquify | 55% | 100–180 |
|
||||
| Adjustments and filters | 70% | 120–200 |
|
||||
| Type | 40% | 120–200 |
|
||||
| Vector shapes and paths | 55% | 80–140 |
|
||||
| Colour management and modes | 70% | 60–100 |
|
||||
| Camera Raw and computational photo | 45% | 120–200 |
|
||||
| Automation, actions, scripting | 45% | 120–200 |
|
||||
| File I/O | 60% | 300–500 |
|
||||
| Print, export, Save for Web | 50% | 60–100 |
|
||||
| Video / timeline, data-driven graphics | 30% | 100–200 |
|
||||
| Workspace, panels, preferences | 40% | 150–250 |
|
||||
| AI / generative | 10% | 300–600 |
|
||||
|
||||
Rows and evidence: [target-app-parity › Feature areas](docs/target-app-parity.md#feature-areas).
|
||||
|
||||
## Languages
|
||||
|
||||
Measured with the `cargo xtask i18n-coverage` key universe (1,608 keys). `partial` everywhere
|
||||
because engine messages are still English. Detail: [localization-parity](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status |
|
||||
|---|---|---:|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 100% | partial |
|
||||
| Spanish | `es` | 100% | partial |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 0% (RTL shell only) | none |
|
||||
| French | `fr` | 100% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% | partial |
|
||||
| Indonesian | `id` | 100% | partial |
|
||||
| Japanese | `ja` | 100% | partial |
|
||||
| German | `de` | 100% | partial |
|
||||
| Korean | `ko` | 100% | partial |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
|
||||
## Upcoming
|
||||
|
||||
| Rank | Milestone | Estimate |
|
||||
|---|---|---:|
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
|
||||
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
|
||||
| 3 | User bug backlog to under 100 open | 250–400 h |
|
||||
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
|
||||
| 5 | The 12 missing tools | 60–100 h |
|
||||
|
||||
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
|
||||
| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster |
|
||||
| 2026-10-05 | 0.2.0, first signed and notarized release. Honest assessment: ready for real work 25–35% |
|
||||
| 2026-10-03 | Menu breadth 625/625 |
|
||||
| 2026-10-01 | Menu breadth 224 → 532 of 625 in a day |
|
||||
| 2026-09-30 | First commit |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
|
||||
| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` |
|
||||
@@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
|
||||
|
||||
## Current limitations
|
||||
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
|
||||
@@ -4,12 +4,6 @@ description = "Headless Photocraft: convert, inspect, run commands, batch, MCP s
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -31,7 +25,3 @@ photocraft-codecs = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//! Windows only: embed the app icon and version info (VERSIONINFO) into `photocraft-cli.exe`.
|
||||
//!
|
||||
//! On every other target this does nothing, even when `PHOTOCRAFT_REQUIRE_WINRES` is set. A
|
||||
//! missing resource compiler is a warning, so a cross-compile from macOS or Linux still links,
|
||||
//! unless `PHOTOCRAFT_REQUIRE_WINRES=1` (set by the release packaging script) turns it into a
|
||||
//! build error.
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../assets/app-icon/photocraft.ico");
|
||||
println!("cargo:rerun-if-env-changed=PHOTOCRAFT_REQUIRE_WINRES");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
|
||||
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
|
||||
// `1.2.3-rc.1` stays in the strings; the numeric fields are `1.2.3.0`.
|
||||
res.set_icon("../../assets/app-icon/photocraft.ico")
|
||||
.set("ProductName", "PhotoCraft")
|
||||
.set("FileDescription", "PhotoCraft command-line interface")
|
||||
.set("CompanyName", "Learning Machines LLC")
|
||||
.set("LegalCopyright", "Copyright (c) the PhotoCraft authors. MIT OR Apache-2.0.")
|
||||
.set("OriginalFilename", "photocraft-cli.exe")
|
||||
.set("InternalName", "photocraft-cli");
|
||||
if let Err(err) = res.compile() {
|
||||
if std::env::var_os("PHOTOCRAFT_REQUIRE_WINRES").is_some() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"embedding Windows resources into photocraft-cli.exe failed: {err}. \
|
||||
Install the Windows SDK resource compiler (rc.exe) or mingw-w64 windres and rebuild."
|
||||
)));
|
||||
}
|
||||
println!("cargo:warning=photocraft-cli.exe built without icon/version resources: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -15,27 +14,15 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
|
||||
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.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
|
||||
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 <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
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
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
|
||||
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
|
||||
when the set holds more than one) with the whole set loaded, so its steps can play other actions
|
||||
of the set. An --out folder that is the --in folder is refused, as the results would replace
|
||||
the originals; --in-place allows it.
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>]
|
||||
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
|
||||
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
|
||||
Run a droplet (File › Automate › Create Droplet) on images and folders.
|
||||
photocraft-cli commands [--json] [--filter <text>]
|
||||
@@ -46,12 +33,8 @@ USAGE:
|
||||
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
|
||||
[--automation-read-root <dir>] [--automation-write-root <dir>]
|
||||
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
|
||||
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
|
||||
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
|
||||
(docs/control-protocol.md#headless-server).
|
||||
|
||||
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
|
||||
an error.
|
||||
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
|
||||
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
|
||||
";
|
||||
|
||||
struct Args {
|
||||
@@ -59,77 +42,43 @@ struct Args {
|
||||
flags: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
|
||||
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
|
||||
struct Subcommand {
|
||||
name: &'static str,
|
||||
values: &'static [&'static str],
|
||||
bare: &'static [&'static str],
|
||||
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], 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"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, _| mcp(a),
|
||||
},
|
||||
Subcommand {
|
||||
name: "serve",
|
||||
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
|
||||
bare: &[],
|
||||
run: |a, _, err| serve(a, err),
|
||||
},
|
||||
const VALUE_FLAGS: &[&str] = &[
|
||||
"--format",
|
||||
"--quality",
|
||||
"--new",
|
||||
"--cmd",
|
||||
"--params",
|
||||
"--out",
|
||||
"--actions",
|
||||
"--in",
|
||||
"--filter",
|
||||
"--bridge",
|
||||
"--port",
|
||||
"--control-token",
|
||||
"--control-token-file",
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
];
|
||||
|
||||
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
|
||||
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
|
||||
fn parse(args: &[String]) -> Result<Args, String> {
|
||||
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
|
||||
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
|
||||
let mut i = 0;
|
||||
while let Some(s) = args.get(i) {
|
||||
if s == "--help" || s == "-h" {
|
||||
return Ok(None);
|
||||
}
|
||||
while i < args.len() {
|
||||
let s = &args[i];
|
||||
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
|
||||
if sub.bare.contains(&k) {
|
||||
return Err(format!("{k} takes no value"));
|
||||
}
|
||||
if !sub.values.contains(&k) {
|
||||
return Err(unknown(k));
|
||||
}
|
||||
a.flags.push((k.to_owned(), Some(v.to_owned())));
|
||||
} else if sub.values.contains(&s.as_str()) {
|
||||
} else if VALUE_FLAGS.contains(&s.as_str()) {
|
||||
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
|
||||
a.flags.push((s.clone(), Some(v.clone())));
|
||||
i += 1;
|
||||
} else if sub.bare.contains(&s.as_str()) {
|
||||
a.flags.push((s.clone(), None));
|
||||
} else if s.starts_with("--") {
|
||||
return Err(unknown(s));
|
||||
a.flags.push((s.clone(), None));
|
||||
} else {
|
||||
a.positional.push(s.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(Some(a))
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
impl Args {
|
||||
@@ -143,30 +92,28 @@ impl Args {
|
||||
|
||||
type R = Result<(), String>;
|
||||
|
||||
/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error
|
||||
/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts
|
||||
/// the process on one (issue #1108).
|
||||
pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let mut strings = Vec::with_capacity(args.len());
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
match a.clone().into_string() {
|
||||
Ok(s) => strings.push(s),
|
||||
Err(raw) => {
|
||||
let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
run(&strings, out, err)
|
||||
}
|
||||
|
||||
/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage).
|
||||
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let Some(cmd) = args.first() else {
|
||||
let _ = write!(err, "{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
match cmd.as_str() {
|
||||
let parsed = match parse(&args[1..]) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let r = match cmd.as_str() {
|
||||
"convert" => convert(&parsed, out, err),
|
||||
"info" => info(&parsed, out),
|
||||
"run" => run_cmds(&parsed, out, err),
|
||||
"batch" => batch(&parsed, out, err),
|
||||
"commands" => commands(&parsed, out),
|
||||
"droplet" => droplet(&parsed, out, err),
|
||||
"mcp" => mcp(&parsed),
|
||||
"serve" => serve(&parsed, err),
|
||||
"-h" | "--help" | "help" => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
@@ -175,25 +122,12 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
|
||||
return 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
|
||||
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
|
||||
return 2;
|
||||
};
|
||||
let parsed = match parse(sub, &args[1..]) {
|
||||
Ok(Some(p)) => p,
|
||||
// `<subcommand> --help`: usage, without reading inputs or starting a server.
|
||||
Ok(None) => {
|
||||
let _ = write!(out, "{USAGE}");
|
||||
return 0;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
|
||||
other => {
|
||||
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
match (sub.run)(&parsed, out, err) {
|
||||
match r {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
let _ = writeln!(err, "error: {e}");
|
||||
@@ -203,21 +137,10 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
|
||||
}
|
||||
|
||||
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
|
||||
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
|
||||
let mut o = ExportOptions::default();
|
||||
if let Some(q) = a.get("--quality") {
|
||||
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
|
||||
o.encode.jpeg_quality = q;
|
||||
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
|
||||
o.encode.webp_quality = q;
|
||||
o.encode.webp_lossless = false;
|
||||
}
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
if let Some(b) = a.get("--dpx-bits") {
|
||||
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"))?);
|
||||
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
|
||||
o.encode.jpeg_quality = q.clamp(1, 100);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
@@ -228,12 +151,6 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine knows which type families aren't installed. Warn before an export
|
||||
/// uses a fallback face rather than silently changing the appearance of the text.
|
||||
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
|
||||
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
|
||||
}
|
||||
|
||||
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
|
||||
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
|
||||
.map_err(|error| error.to_string())
|
||||
@@ -243,11 +160,9 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let [input, output] = a.positional.as_slice() else {
|
||||
return Err("convert needs <in> <out>".into());
|
||||
};
|
||||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
}
|
||||
@@ -264,11 +179,7 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
|
||||
let mut h = Headless::trusted_local();
|
||||
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
|
||||
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
|
||||
if let Some(st) = h.session.active() {
|
||||
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
|
||||
}
|
||||
doc["warnings"] = Value::Array(warnings);
|
||||
doc["warnings"] = opened["warnings"].clone();
|
||||
doc["file"] = json!(file);
|
||||
// Drop per-session noise.
|
||||
if let Value::Object(m) = &mut doc {
|
||||
@@ -296,22 +207,16 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
// Opening, running commands and exporting can all notice the same missing font:
|
||||
// one `run` invocation reports each warning line at most once.
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
|
||||
reported_fonts.extend(ws.iter().cloned());
|
||||
warn_all(err, &ws);
|
||||
reported_fonts.extend(ws);
|
||||
}
|
||||
([], Some(new)) => {
|
||||
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
|
||||
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
|
||||
h.command_run("file.new", p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
|
||||
}
|
||||
@@ -321,126 +226,36 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
}
|
||||
for (id, params) in cmds {
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
return Err(format!("`{id}`: {e}"));
|
||||
}
|
||||
let new_warnings: Vec<String> = h
|
||||
.session
|
||||
.active()
|
||||
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|w| reported_fonts.insert(w.clone()))
|
||||
.collect();
|
||||
warn_all(err, &new_warnings);
|
||||
let mut result = json!({"command": id, "result": r});
|
||||
if !new_warnings.is_empty() {
|
||||
result["warnings"] = json!(new_warnings);
|
||||
}
|
||||
print_json(out, &result, true)?;
|
||||
print_json(out, &json!({"command": id, "result": r}), true)?;
|
||||
}
|
||||
// A `run` with only `--out` still reports missing fonts before exporting.
|
||||
let remaining: Vec<String> = h
|
||||
.session
|
||||
.active()
|
||||
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|w| reported_fonts.insert(w.clone()))
|
||||
.collect();
|
||||
warn_all(err, &remaining);
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
|
||||
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
|
||||
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
|
||||
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
|
||||
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
|
||||
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
steps_of(&v)
|
||||
}
|
||||
|
||||
fn steps_of(v: &Value) -> Result<Steps, String> {
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Engine commands to run in order: `(id, params)`.
|
||||
pub type Steps = Vec<(String, Value)>;
|
||||
|
||||
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
|
||||
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
|
||||
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
|
||||
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
|
||||
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
|
||||
let list = v.get("actions").unwrap_or(&v);
|
||||
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
|
||||
if !is_set {
|
||||
if name.is_some() {
|
||||
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
|
||||
}
|
||||
return Ok((steps_of(&v)?, Vec::new()));
|
||||
}
|
||||
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
|
||||
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
|
||||
let action = match (name, set.as_slice()) {
|
||||
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
|
||||
(None, [only]) => only,
|
||||
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
|
||||
let list = match &v {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
|
||||
_ => return Err("actions JSON: expected an array".into()),
|
||||
};
|
||||
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
|
||||
Ok((play, set))
|
||||
}
|
||||
|
||||
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
|
||||
/// its version word) is converted first; the played action's steps with no PhotoCraft
|
||||
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
|
||||
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
|
||||
if !is_atn {
|
||||
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let (steps, set) = load_actions(&text, name)?;
|
||||
return Ok((steps, set, Vec::new()));
|
||||
}
|
||||
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
|
||||
let action = match (name, imp.actions.as_slice()) {
|
||||
(Some(n), _) => {
|
||||
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
|
||||
}
|
||||
(None, [only]) => only,
|
||||
(None, []) => return Err(format!("{path}: the set holds no actions")),
|
||||
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
|
||||
};
|
||||
let warnings = imp
|
||||
.unsupported
|
||||
.iter()
|
||||
.filter(|u| u.action == action.name)
|
||||
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
|
||||
.chain(imp.warnings.iter().cloned())
|
||||
.collect();
|
||||
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
|
||||
Ok((play, imp.actions, warnings))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
/// trailing separator, through a symbolic link).
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
|
||||
list.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
|
||||
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
|
||||
};
|
||||
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
|
||||
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
@@ -448,48 +263,23 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
|
||||
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
|
||||
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
|
||||
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
|
||||
for w in &warnings {
|
||||
let _ = writeln!(err, "warning: {w}");
|
||||
}
|
||||
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
|
||||
let actions = parse_actions(&text)?;
|
||||
let opts = export_opts(a)?;
|
||||
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
|
||||
// replace the originals (#492).
|
||||
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
|
||||
return Err(format!(
|
||||
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
|
||||
out_dir.display()
|
||||
));
|
||||
}
|
||||
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
||||
let mut inputs: Vec<PathBuf> =
|
||||
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
|
||||
inputs.sort();
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
|
||||
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
|
||||
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
|
||||
for input in &inputs {
|
||||
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let target = out_dir.join(format!("{stem}.{ext}"));
|
||||
let target_text = target.to_string_lossy();
|
||||
let r = (|| -> Result<Vec<String>, String> {
|
||||
written.check(&target_text)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
// Steps that play another action find it in the set; the session's playback stack
|
||||
// stops an action that calls itself.
|
||||
h.session.actions.list = set.clone();
|
||||
h.open(input).map_err(|e| e.to_string())?;
|
||||
for (id, p) in &actions {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
// A failed step inside a called action makes this file an error, not a saved result.
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
|
||||
return Err(format!("`{id}`: {e}"));
|
||||
}
|
||||
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
}
|
||||
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
|
||||
@@ -497,7 +287,6 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
match r {
|
||||
Ok(ws) => {
|
||||
ok += 1;
|
||||
written.record(&target_text, &input.to_string_lossy());
|
||||
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
@@ -541,7 +330,9 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
|
||||
.map(|v| v.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
|
||||
.filter(|c| {
|
||||
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
|
||||
})
|
||||
.collect();
|
||||
if a.has("--json") {
|
||||
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
|
||||
@@ -591,164 +382,3 @@ fn mcp(a: &Args) -> R {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
|
||||
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod missing_font_warning_tests {
|
||||
use super::*;
|
||||
|
||||
fn invoke(args: &[&str]) -> (i32, String, String) {
|
||||
let mut output = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let code = run(&args, &mut output, &mut errors);
|
||||
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_typeface_is_reported_by_run_info_and_convert() {
|
||||
let family = "DefinitelyMissingPhotoCraftTypeface1251";
|
||||
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let native = folder.join("missing.pcraft");
|
||||
let png = folder.join("missing.png");
|
||||
let native_str = native.to_string_lossy().to_string();
|
||||
let png_str = png.to_string_lossy().to_string();
|
||||
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
|
||||
|
||||
let (code, stdout, stderr) = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":256,"height":96,"background":"white"}"#,
|
||||
"--cmd",
|
||||
"type.create",
|
||||
"--params",
|
||||
¶ms,
|
||||
"--cmd",
|
||||
"type.resolveMissingFonts",
|
||||
"--out",
|
||||
&native_str,
|
||||
]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
|
||||
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
|
||||
assert_eq!(commands.len(), 2, "{stdout}");
|
||||
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
|
||||
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
|
||||
|
||||
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
|
||||
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
|
||||
|
||||
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
|
||||
assert!(png.is_file());
|
||||
|
||||
// Even with no commands, run --out checks the opened document before export.
|
||||
let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(stdout.is_empty());
|
||||
assert_eq!(stderr.matches(family).count(), 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_reads_and_writes_openraster() {
|
||||
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
|
||||
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
|
||||
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let (code, _, stderr) = invoke(&["convert", &native, &ora]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
|
||||
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(is_input(std::path::Path::new(&ora)));
|
||||
// Krita documents are batch inputs too (import-only).
|
||||
for name in ["sketch.kra", "old.KRZ"] {
|
||||
std::fs::write(folder.join(name), b"").unwrap();
|
||||
assert!(is_input(&folder.join(name)), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_type_layers_do_not_produce_missing_font_warnings() {
|
||||
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let native = folder.join("blank.pcraft");
|
||||
let png = folder.join("blank.png");
|
||||
let native_str = native.to_string_lossy().to_string();
|
||||
let png_str = png.to_string_lossy().to_string();
|
||||
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(!stderr.contains("font '"), "{stderr}");
|
||||
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
|
||||
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(!stderr.contains("font '"), "{stderr}");
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod batch_action_set_tests {
|
||||
use super::*;
|
||||
|
||||
fn invoke(args: &[&str]) -> (i32, String, String) {
|
||||
let (mut output, mut errors) = (Vec::new(), Vec::new());
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let code = run(&args, &mut output, &mut errors);
|
||||
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
|
||||
}
|
||||
|
||||
/// A batched action that plays another action of its set finds it in every file's session; a
|
||||
/// failure inside the called action fails the file, and a self-call stops instead of looping.
|
||||
#[test]
|
||||
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
std::fs::create_dir_all(dir.join("in")).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
}
|
||||
let set = json!({"version": 1, "actions": [
|
||||
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
|
||||
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
|
||||
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
|
||||
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
|
||||
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
|
||||
]});
|
||||
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
|
||||
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
|
||||
|
||||
let (code, stdout, stderr) = batch("Outer");
|
||||
assert_eq!(code, 0, "{stdout}{stderr}");
|
||||
for name in ["a", "b"] {
|
||||
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
|
||||
}
|
||||
for action in ["Calls bad", "Loop"] {
|
||||
let (code, stdout, stderr) = batch(action);
|
||||
assert_ne!(code, 0, "{stdout}");
|
||||
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
|
||||
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
|
||||
// usage error, not a panic before anything is printed (issue #1108).
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let code = photocraft_cli::run(&args, &mut std::io::stdout(), &mut std::io::stderr());
|
||||
std::process::exit(code);
|
||||
}
|
||||
@@ -43,68 +43,6 @@ fn usage_and_unknown_command() {
|
||||
assert!(out.starts_with("photocraft-cli "));
|
||||
}
|
||||
|
||||
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
|
||||
/// written, no server started), whatever else is on the line.
|
||||
#[test]
|
||||
fn every_subcommand_answers_help() {
|
||||
let d = tmp("help");
|
||||
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
|
||||
for help in ["--help", "-h"] {
|
||||
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
|
||||
}
|
||||
|
||||
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors() {
|
||||
let d = tmp("flags");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let fails = |cmd: &mut Command, flag: &str| {
|
||||
let o = cmd.output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
|
||||
};
|
||||
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
|
||||
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
|
||||
assert!(!out_dir.exists(), "nothing written");
|
||||
}
|
||||
let x = d.join("x.png");
|
||||
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
|
||||
assert!(!x.exists());
|
||||
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
|
||||
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
|
||||
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||||
for needle in ["ellipse", "marquee"] {
|
||||
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
|
||||
assert!(out.contains("select.rect"), "{needle}: {out}");
|
||||
}
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
"80",
|
||||
]));
|
||||
assert!(out_dir.join("a.jpg").exists());
|
||||
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
|
||||
assert!(!out.trim().contains('\n'), "compact JSON is one line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_png_pcraft_png_is_lossless() {
|
||||
let d = tmp("convert");
|
||||
@@ -141,45 +79,13 @@ fn info_prints_layer_tree_json() {
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["width"], 10);
|
||||
assert_eq!(v["height"], 6);
|
||||
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
|
||||
assert_eq!(v["layers"][0]["name"], "Layer 0");
|
||||
assert_eq!(v["layers"][0]["name"], "Background");
|
||||
assert!(v.get("history").is_none());
|
||||
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
|
||||
assert_eq!(out.trim().lines().count(), 1);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
|
||||
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
|
||||
#[test]
|
||||
fn info_and_convert_report_decode_warnings() {
|
||||
let d = tmp("decode-warnings");
|
||||
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
|
||||
// Three 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..3 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
];
|
||||
for (name, bytes, warning) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_commands_and_save() {
|
||||
let d = tmp("run");
|
||||
@@ -194,11 +100,9 @@ fn run_commands_and_save() {
|
||||
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[2]["command"], "document.inspect");
|
||||
assert_eq!(lines[2]["result"]["name"], "Run");
|
||||
let (info, _) = ok(bin().arg("info").arg(&out_pc));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
// Saving a native document gives the reopened bundle its output filename.
|
||||
assert_eq!(v["name"], "r.pcraft");
|
||||
assert_eq!(v["name"], "Run");
|
||||
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Ink", "Background"]);
|
||||
// Continue from the saved file and export PNG.
|
||||
@@ -208,50 +112,6 @@ fn run_commands_and_save() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stops_after_failed_action_without_exporting() {
|
||||
let d = tmp("run-failed-action");
|
||||
let out = d.join("result.png");
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "layer.new.layer"])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
|
||||
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
|
||||
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
|
||||
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
|
||||
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
|
||||
assert!(!out.exists(), "a failed action must not export a partial result");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_continues_after_successful_action_and_exports() {
|
||||
let d = tmp("run-successful-action");
|
||||
let out = d.join("result.png");
|
||||
let (stdout, _) = ok(bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
|
||||
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out));
|
||||
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
|
||||
assert_eq!(playback["result"]["ran"], 1);
|
||||
assert!(playback["result"].get("failed").is_none());
|
||||
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
|
||||
assert!(out.is_file());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_errors() {
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
|
||||
@@ -261,33 +121,6 @@ fn run_errors() {
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
// Params must be a JSON object, for `--new` and for each `--params`.
|
||||
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_invalid_filter_parameters_without_exporting() {
|
||||
let d = tmp("invalid-filter-params");
|
||||
let out = d.join("result.png");
|
||||
for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] {
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(result.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -315,37 +148,6 @@ fn batch_applies_actions_to_directory() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_recorded_view_and_save_steps() {
|
||||
let d = tmp("batch-shell-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let source = input.join("a.png");
|
||||
write_png(&source, 12, 8, 3);
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let recorded_save = d.join("recorded-save.psd");
|
||||
let mut cases = Vec::new();
|
||||
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
|
||||
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
}
|
||||
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
|
||||
for (name, actions) in cases {
|
||||
let o = batch_with(&d, &input, name, &actions, &[]);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
|
||||
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
|
||||
let output = d.join(name).join("a.png");
|
||||
let (info, _) = ok(bin().arg("info").arg(&output));
|
||||
let info: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
|
||||
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_actions_formats() {
|
||||
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||||
@@ -353,217 +155,6 @@ fn parse_actions_formats() {
|
||||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||||
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
|
||||
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
|
||||
for text in [
|
||||
r#"[["a",{"x":1}],["b"]]"#,
|
||||
r#"[["a",{"x":1}],"b"]"#,
|
||||
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
|
||||
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
|
||||
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
|
||||
] {
|
||||
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
|
||||
}
|
||||
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
|
||||
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
|
||||
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
|
||||
let file = d.join(format!("{name}.json"));
|
||||
std::fs::write(&file, actions).unwrap();
|
||||
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
|
||||
}
|
||||
|
||||
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
|
||||
/// as the object form; a malformed step fails before anything is written.
|
||||
#[test]
|
||||
fn batch_accepts_recorded_action_steps() {
|
||||
let d = tmp("batch-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let shapes = [
|
||||
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
|
||||
("pairs", r#"[["image.adjustments.invert",{}]]"#),
|
||||
("ids", r#"["image.adjustments.invert"]"#),
|
||||
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
|
||||
];
|
||||
for (name, actions) in shapes {
|
||||
let o = batch_with(&d, &input, name, actions, &[]);
|
||||
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
|
||||
for f in ["a.png", "b.png"] {
|
||||
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
|
||||
}
|
||||
}
|
||||
let o = batch_with(&d, &input, "bad", "[42]", &[]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(!d.join("bad").exists(), "nothing written");
|
||||
}
|
||||
|
||||
/// #490: a leading dot on `--format` doesn't double the dot in output names.
|
||||
#[test]
|
||||
fn batch_format_with_leading_dot() {
|
||||
let d = tmp("batch-dot");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
|
||||
let o = batch_with(&d, &input, name, invert, &["--format", format]);
|
||||
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
|
||||
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
|
||||
}
|
||||
}
|
||||
|
||||
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
|
||||
/// and nothing is written.
|
||||
#[test]
|
||||
fn quality_outside_1_to_100_is_refused() {
|
||||
let d = tmp("quality");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let a = input.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
|
||||
let out = d.join("q.jpg");
|
||||
let (mut convert, mut run) = (bin(), bin());
|
||||
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
|
||||
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
|
||||
for cmd in [&mut convert, &mut run] {
|
||||
let o = cmd.output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--quality {q}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
|
||||
assert!(!out.exists(), "--quality {q} wrote a file");
|
||||
}
|
||||
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
|
||||
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
|
||||
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
|
||||
}
|
||||
// The documented range still encodes, lower quality making a smaller file.
|
||||
let sizes: Vec<usize> = ["1", "50", "100"]
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let out = d.join(format!("q{q}.jpg"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
|
||||
std::fs::read(out).unwrap().len()
|
||||
})
|
||||
.collect();
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||||
#[test]
|
||||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
let d = tmp("dpx");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||||
let out = d.join("a.dpx");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--dpx-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||||
}
|
||||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||||
assert!(back.exists());
|
||||
for bad in ["24", "0", "abc"] {
|
||||
let out = d.join("bad.dpx");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||||
assert!(!out.exists(), "--dpx-bits {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]
|
||||
fn tga_bits_choose_the_targa_depth() {
|
||||
let d = tmp("tga-bits");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
|
||||
let out = d.join("a.tga");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--tga-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
|
||||
}
|
||||
for bad in ["16", "0", "abc", ""] {
|
||||
let out = d.join("bad.tga");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
|
||||
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
fn batch_refuses_to_write_over_its_inputs() {
|
||||
let d = tmp("batch-in-place");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let snapshot = || {
|
||||
let mut files: Vec<(PathBuf, Vec<u8>)> =
|
||||
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let before = snapshot();
|
||||
let batch = |out: &Path, extra: &[&str]| {
|
||||
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
|
||||
let link = cfg!(unix).then(|| PathBuf::from("link"));
|
||||
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
|
||||
for extra in [&[][..], &["--format", "tif"][..]] {
|
||||
let o = batch(out, extra);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||||
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
|
||||
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
|
||||
}
|
||||
}
|
||||
// A separate folder runs as before, leaving the originals alone.
|
||||
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
|
||||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||||
assert_eq!(snapshot(), before);
|
||||
// `--in-place` opts in: each result replaces its original.
|
||||
let o = batch(Path::new("in"), &["--in-place"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let after = snapshot();
|
||||
assert_eq!(after.len(), 2);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -627,38 +218,6 @@ fn mcp_stdio_handshake_and_tool_call() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
|
||||
#[test]
|
||||
fn batch_fails_inputs_that_share_an_output_name() {
|
||||
let d = tmp("batch-collide");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
// Same stem in other formats; `A.tif` differs only in case.
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
|
||||
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"a lost result must fail the run
|
||||
{out}
|
||||
{err}"
|
||||
);
|
||||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||||
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
|
||||
// Keeping each file's format, every input gets its own output.
|
||||
let out_dir = d.join("same");
|
||||
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droplet_runs_an_action_on_files() {
|
||||
let d = tmp("droplet");
|
||||
@@ -678,78 +237,3 @@ fn droplet_runs_an_action_on_files() {
|
||||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||||
assert!(!o.status.success(), "not a droplet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
|
||||
let d = tmp("tiff-layers");
|
||||
let layer_count = |path: &std::path::Path| -> usize {
|
||||
let (info, _) = ok(bin().arg("info").arg(path));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
v["layers"].as_array().unwrap().len()
|
||||
};
|
||||
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
|
||||
let out = d.join(name);
|
||||
let mut cmd = bin();
|
||||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
|
||||
if let Some(flag) = flag {
|
||||
cmd.arg(flag);
|
||||
}
|
||||
ok(cmd.arg("--out").arg(&out));
|
||||
assert_eq!(layer_count(&out), layers, "{name}");
|
||||
}
|
||||
// convert honours the same flag.
|
||||
let src = d.join("layered.tif");
|
||||
let flat = d.join("converted.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&flat));
|
||||
assert_eq!(layer_count(&flat), 1);
|
||||
let kept = d.join("kept.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
|
||||
assert_eq!(layer_count(&kept), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
|
||||
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
|
||||
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
|
||||
let o = bin().args(&args).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
|
||||
assert!(!err.contains("panicked"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
|
||||
/// PhotoCraft can't play are warned about up front.
|
||||
#[test]
|
||||
fn batch_plays_a_photoshop_actions_file() {
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
let d = tmp("atn");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
|
||||
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
|
||||
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let atn = d.join("Set.atn");
|
||||
std::fs::write(&atn, bytes).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
// Two actions: the set needs --action.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out_dir.join("a.png").exists());
|
||||
assert!(!err.contains("warning"), "{err}");
|
||||
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(err.contains("Generative Fill"), "{err}");
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
|
||||
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
|
||||
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
|
||||
//! changes nothing.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> String {
|
||||
let o = cmd.output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
|
||||
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
|
||||
out
|
||||
}
|
||||
|
||||
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
|
||||
fn exif(o: u16) -> Vec<u8> {
|
||||
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
|
||||
v.extend_from_slice(&o.to_le_bytes());
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
|
||||
v.extend_from_slice(b"PC1\0");
|
||||
v.extend_from_slice(&[0, 0, 0, 0]);
|
||||
v
|
||||
}
|
||||
|
||||
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
|
||||
fn upright_color(x: usize, y: usize) -> [u8; 3] {
|
||||
match (x < 8, y < 16) {
|
||||
(true, true) => [230, 20, 20],
|
||||
(false, true) => [20, 230, 20],
|
||||
_ => [20, 20, 230],
|
||||
}
|
||||
}
|
||||
|
||||
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
let (w, h) = (16, 32);
|
||||
let mut px = vec![0u8; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
|
||||
let (sx, sy) = (y, w - 1 - x);
|
||||
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
|
||||
}
|
||||
}
|
||||
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
|
||||
let jpeg =
|
||||
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
|
||||
let mut seg = b"Exif\0\0".to_vec();
|
||||
seg.extend_from_slice(&exif(6));
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
|
||||
fn stored(path: &Path) -> Image {
|
||||
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn assert_portrait_pixels(img: &Image) {
|
||||
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
|
||||
let i = (y * 16 + x) * 3;
|
||||
let got = &img.data()[i..i + 3];
|
||||
let want = upright_color(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let d = tmp("e2e");
|
||||
let jpg = d.join("IMG_0006.JPG");
|
||||
std::fs::write(&jpg, phone_jpeg()).unwrap();
|
||||
|
||||
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
|
||||
let png = d.join("upright.png");
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&png));
|
||||
let raw = stored(&png);
|
||||
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
|
||||
assert_portrait_pixels(&raw);
|
||||
if let Some(e) = raw.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
|
||||
}
|
||||
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
|
||||
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
|
||||
|
||||
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
|
||||
let turned = d.join("turned.png");
|
||||
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
|
||||
let t = stored(&turned);
|
||||
assert_eq!(t.dimensions(), (32, 16));
|
||||
if let Some(e) = t.meta.exif.as_deref() {
|
||||
assert_eq!(exif_orientation(e), 1);
|
||||
}
|
||||
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
|
||||
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
|
||||
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
|
||||
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
|
||||
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
|
||||
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
|
||||
|
||||
// .pcraft save, reopen and save again: identical document and pixels.
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path and saved document name must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
v.as_object_mut().unwrap().remove("path");
|
||||
v
|
||||
};
|
||||
let (mut ia, mut ib) = (info(&a), info(&b));
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia["name"], "a.pcraft");
|
||||
assert_eq!(ib["name"], "b.pcraft");
|
||||
ia.as_object_mut().unwrap().remove("name");
|
||||
ib.as_object_mut().unwrap().remove("name");
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
ok(bin().arg("convert").arg(&b).arg(&pb));
|
||||
let (xa, xb) = (stored(&pa), stored(&pb));
|
||||
assert_eq!(xa.dimensions(), (16, 32));
|
||||
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
|
||||
assert_eq!(xa.meta.exif, xb.meta.exif);
|
||||
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -6,11 +6,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -29,14 +24,9 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
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"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }
|
||||
@@ -4,27 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
|
||||
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
|
||||
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
|
||||
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
|
||||
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
|
||||
@keyframes photocraft-stalled { to { visibility: visible; } }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</p>
|
||||
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
<div id="photocraft_loading">Loading PhotoCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,255 +0,0 @@
|
||||
//! IndexedDB owns durable bytes; the engine sees only the bounded, Send memory bridge.
|
||||
//! Each batch commits tips, groups and index together, and success means transaction completion.
|
||||
use js_sys::{Array, Function, Promise, Uint8Array};
|
||||
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Blob, Event, EventTarget, IdbDatabase, IdbRequest, IdbTransactionMode};
|
||||
|
||||
use crate::preset_bridge::{Batch, Bridge, MAX_RECORDS, record_limit};
|
||||
use photocraft_engine::preset_store::MAX_STORE_BYTES;
|
||||
|
||||
const DATABASE: &str = "photocraft.presets";
|
||||
const FILES: &str = "files";
|
||||
const TIMEOUT_MS: f64 = 10_000.0;
|
||||
|
||||
fn js(e: JsValue) -> String {
|
||||
if let Some(exception) = e.dyn_ref::<web_sys::DomException>() {
|
||||
format!("{}: {}", exception.name(), exception.message())
|
||||
} else {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Listeners are detached even when startup or a write times out.
|
||||
type Listener = (EventTarget, &'static str, Closure<dyn FnMut(Event)>);
|
||||
struct Listeners(Vec<Listener>);
|
||||
impl Listeners {
|
||||
fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
fn add(&mut self, target: &EventTarget, name: &'static str, f: impl FnMut(Event) + 'static) -> Result<(), JsValue> {
|
||||
let cb = Closure::<dyn FnMut(Event)>::new(f);
|
||||
target.add_event_listener_with_callback(name, cb.as_ref().unchecked_ref())?;
|
||||
self.0.push((target.clone(), name, cb));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for Listeners {
|
||||
fn drop(&mut self) {
|
||||
for (target, name, cb) in &self.0 {
|
||||
let _ = target.remove_event_listener_with_callback(name, cb.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
promise: Promise,
|
||||
_listeners: Listeners,
|
||||
}
|
||||
|
||||
fn request(request: &IdbRequest) -> Pending {
|
||||
let mut listeners = Listeners::new();
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
let req = request.clone();
|
||||
let failed = reject.clone();
|
||||
if let Err(e) = listeners.add(request, "success", move |_| match req.result() {
|
||||
Ok(v) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &v);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = failed.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}) {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
let req = request.clone();
|
||||
let failed = reject.clone();
|
||||
if let Err(e) = listeners.add(request, "error", move |_| {
|
||||
let e = req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| JsValue::from_str("IndexedDB request failed"));
|
||||
let _ = failed.call1(&JsValue::NULL, &e);
|
||||
}) {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
});
|
||||
Pending { promise, _listeners: listeners }
|
||||
}
|
||||
|
||||
/// A single deadline bounds the whole startup read, not ten seconds per record.
|
||||
async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let mut timer = None;
|
||||
let mut callback = None;
|
||||
let timeout = Promise::new(&mut |_, reject| {
|
||||
let cb = Closure::<dyn FnMut()>::new(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("browser preset storage timed out"));
|
||||
});
|
||||
timer = Some(window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
cb.as_ref().unchecked_ref(),
|
||||
(deadline - js_sys::Date::now()).clamp(0.0, TIMEOUT_MS) as i32,
|
||||
));
|
||||
callback = Some(cb);
|
||||
});
|
||||
let id = timer.ok_or("could not start storage timeout")?.map_err(js)?;
|
||||
let result = JsFuture::from(Promise::race(&Array::of2(promise, &timeout))).await.map_err(js);
|
||||
window.clear_timeout_with_handle(id);
|
||||
drop(callback);
|
||||
result
|
||||
}
|
||||
|
||||
async fn open(deadline: f64) -> Result<IdbDatabase, String> {
|
||||
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?;
|
||||
let req = factory.open_with_u32(DATABASE, 1).map_err(js)?;
|
||||
let mut upgrade = Listeners::new();
|
||||
let upgrade_req = req.clone();
|
||||
upgrade
|
||||
.add(&req, "upgradeneeded", move |_| {
|
||||
let result = upgrade_req.result().and_then(|v| v.dyn_into::<IdbDatabase>()).and_then(|db| db.create_object_store(FILES));
|
||||
if result.is_err()
|
||||
&& let Some(tx) = upgrade_req.transaction()
|
||||
{
|
||||
let _ = tx.abort();
|
||||
}
|
||||
})
|
||||
.map_err(js)?;
|
||||
let pending = request(&req);
|
||||
match before(&pending.promise, deadline).await {
|
||||
Ok(value) => value.dyn_into().map_err(|_| "invalid preset database".into()),
|
||||
Err(e) => {
|
||||
// An open request cannot be cancelled (another tab may block its upgrade). Keep
|
||||
// its handlers alive and close any late connection; never hydrate a live session.
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Ok(value) = JsFuture::from(pending.promise.clone()).await
|
||||
&& let Ok(db) = value.dyn_into::<IdbDatabase>()
|
||||
{
|
||||
db.close();
|
||||
}
|
||||
drop(pending);
|
||||
drop(upgrade);
|
||||
});
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load() -> Result<(IdbDatabase, Bridge, Vec<String>), String> {
|
||||
let deadline = js_sys::Date::now() + TIMEOUT_MS;
|
||||
let db = open(deadline).await?;
|
||||
let result = read(&db, deadline).await;
|
||||
match result {
|
||||
Ok((bridge, warnings)) => Ok((db, bridge, warnings)),
|
||||
Err(e) => {
|
||||
db.close();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec<String>), String> {
|
||||
let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;
|
||||
let req = tx.object_store(FILES).map_err(js)?.open_cursor().map_err(js)?;
|
||||
let mut records = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut unreadable = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut seen = 0usize;
|
||||
let mut next = request(&req);
|
||||
loop {
|
||||
let value = before(&next.promise, deadline).await?;
|
||||
if value.is_null() {
|
||||
break;
|
||||
}
|
||||
let cursor = value.dyn_into::<web_sys::IdbCursorWithValue>().map_err(|_| "invalid preset cursor")?;
|
||||
seen += 1;
|
||||
if seen > MAX_RECORDS {
|
||||
return Err(format!("browser preset record limit ({MAX_RECORDS}) exceeded"));
|
||||
}
|
||||
let key = cursor.key().map_err(js)?;
|
||||
// Inspect key length in JS before allocating its Rust String.
|
||||
let name = if key.is_string() && js_sys::JsString::from(key.clone()).length() <= 133 { key.as_string().unwrap_or_default() } else { String::new() };
|
||||
let blob = cursor.value().map_err(js)?.dyn_into::<Blob>();
|
||||
let record = record_limit(&name).and_then(|limit| {
|
||||
let blob = blob.map_err(|_| "record is not a Blob".to_string())?;
|
||||
if blob.size() > limit as f64 {
|
||||
return Err("record is too large".into());
|
||||
}
|
||||
Ok(blob)
|
||||
});
|
||||
match record {
|
||||
Ok(blob) => {
|
||||
total = total.saturating_add(blob.size() as u64);
|
||||
if total > MAX_STORE_BYTES {
|
||||
return Err("browser preset store exceeds size limit".into());
|
||||
}
|
||||
records.push((name, blob));
|
||||
}
|
||||
Err(e) => {
|
||||
// Leave an unreadable placeholder in the engine's file inventory. In
|
||||
// particular, a skipped group must protect its tip records from orphan GC.
|
||||
if record_limit(&name).is_ok() {
|
||||
unreadable.push(name.clone());
|
||||
}
|
||||
if warnings.len() < 8 {
|
||||
warnings.push(format!("Brush presets: {name} skipped: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Register the next callback before continuing the cursor. Blob reads happen after
|
||||
// enumeration, since awaiting them here would let this transaction become inactive.
|
||||
next = request(&req);
|
||||
cursor.continue_().map_err(js)?;
|
||||
}
|
||||
let bridge = Bridge::default();
|
||||
for name in unreadable {
|
||||
bridge.load(&name, Vec::new())?;
|
||||
}
|
||||
for (name, blob) in records {
|
||||
let buffer = before(&blob.array_buffer(), deadline).await?;
|
||||
bridge.load(&name, Uint8Array::new(&buffer).to_vec())?;
|
||||
}
|
||||
Ok((bridge, warnings))
|
||||
}
|
||||
|
||||
pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> {
|
||||
let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?;
|
||||
let store = tx.object_store(FILES).map_err(js)?;
|
||||
let queued = (|| -> Result<(), String> {
|
||||
for (name, bytes) in batch {
|
||||
let key = JsValue::from_str(name);
|
||||
if let Some(bytes) = bytes {
|
||||
let blob = Blob::new_with_u8_array_sequence(&Array::of1(&Uint8Array::from(bytes.as_ref()))).map_err(js)?;
|
||||
store.put_with_key(&blob, &key).map_err(js)?;
|
||||
} else {
|
||||
store.delete(&key).map_err(js)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = queued {
|
||||
let _ = tx.abort();
|
||||
return Err(e);
|
||||
}
|
||||
let mut listeners = Listeners::new();
|
||||
let promise = Promise::new(&mut |resolve, reject| {
|
||||
if let Err(e) = listeners.add(&tx, "complete", move |_| {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
}) {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
for event in ["abort", "error"] {
|
||||
let tx = tx.clone();
|
||||
let failed: Function = reject.clone();
|
||||
let target: EventTarget = tx.clone().into();
|
||||
if let Err(e) = listeners.add(&target, event, move |_| {
|
||||
let e = tx.error().map(JsValue::from).unwrap_or_else(|| JsValue::from_str("preset transaction aborted"));
|
||||
let _ = failed.call1(&JsValue::NULL, &e);
|
||||
}) {
|
||||
let _ = reject.call1(&JsValue::NULL, &e);
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = before(&promise, js_sys::Date::now() + TIMEOUT_MS).await.map(|_| ());
|
||||
if result.is_err() {
|
||||
let _ = tx.abort();
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -16,12 +16,6 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod preset_bridge;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod indexed_presets;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
@@ -34,18 +28,3 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
|
||||
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
|
||||
/// retry, without a hand-written script.
|
||||
#[test]
|
||||
fn loading_page_offers_a_reload_when_startup_stalls() {
|
||||
let html = include_str!("../index.html");
|
||||
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
|
||||
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
|
||||
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
|
||||
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
//! Synchronous engine boundary for asynchronous browser storage. No browser handles cross the
|
||||
//! engine's `Send` boundary. Byte buffers are shared with the single in-flight transaction.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use photocraft_engine::{
|
||||
Session,
|
||||
preset_store::{self, PresetBackend, *},
|
||||
};
|
||||
|
||||
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
|
||||
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
|
||||
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
files: BTreeMap<String, Arc<[u8]>>,
|
||||
bytes: u64,
|
||||
dirty: BTreeSet<String>,
|
||||
writing: bool,
|
||||
error: Option<String>,
|
||||
rejected: BTreeMap<String, String>,
|
||||
preserve_tips: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Bridge(Arc<Mutex<State>>);
|
||||
|
||||
pub fn session(backend: Bridge) -> (Session, Vec<String>) {
|
||||
let mut session = Session::new();
|
||||
let opened = preset_store::open(Box::new(backend.clone()));
|
||||
// An unreadable group may reference any tip, including one shared with a good group.
|
||||
// Keep all tips until the damaged library can be recovered; unrelated edits still save.
|
||||
backend.state().preserve_tips = !opened.warnings.is_empty();
|
||||
let warnings = session.attach_preset_store(opened);
|
||||
(session, warnings)
|
||||
}
|
||||
|
||||
/// Check metadata before copying a browser record into Rust memory.
|
||||
pub fn record_limit(name: &str) -> Result<u64, String> {
|
||||
let leaf = name.strip_prefix("tips/").unwrap_or(name);
|
||||
if leaf.is_empty() || leaf.len() > 128 || leaf.starts_with('.') || !leaf.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '_' | '-' | '.')) {
|
||||
return Err("invalid preset record name".into());
|
||||
}
|
||||
match name {
|
||||
INDEX_FILE => Ok(MAX_INDEX_BYTES),
|
||||
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
|
||||
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
|
||||
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
|
||||
_ => Err("unknown preset record type".into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
fn state(&self) -> MutexGuard<'_, State> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn insert(s: &mut State, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if bytes.len() as u64 > record_limit(name)? {
|
||||
return Err(format!("{name}: preset record too large"));
|
||||
}
|
||||
let old = s.files.get(name).map_or(0, |b| b.len() as u64);
|
||||
let size = s.bytes.saturating_sub(old).saturating_add(bytes.len() as u64);
|
||||
if size > MAX_STORE_BYTES || (!s.files.contains_key(name) && s.files.len() >= MAX_RECORDS) {
|
||||
return Err("browser preset store is full".into());
|
||||
}
|
||||
s.files.insert(name.to_string(), Arc::from(bytes));
|
||||
s.bytes = size;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(&self, name: &str, bytes: Vec<u8>) -> Result<(), String> {
|
||||
Self::insert(&mut self.state(), name, &bytes)
|
||||
}
|
||||
|
||||
pub fn begin(&self) -> Option<Batch> {
|
||||
let mut s = self.state();
|
||||
if s.writing || s.error.is_some() || s.dirty.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.writing = true;
|
||||
Some(
|
||||
std::mem::take(&mut s.dirty)
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let bytes = s.files.get(&name).cloned();
|
||||
(name, bytes)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn finish(&self, batch: Batch, result: Result<(), String>) {
|
||||
let mut s = self.state();
|
||||
s.writing = false;
|
||||
if let Err(e) = result {
|
||||
// Requeue names, never old bytes: edits/deletes made during the failed write win.
|
||||
s.dirty.extend(batch.into_keys());
|
||||
s.error = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable(&self, error: String) {
|
||||
self.state().error = Some(error);
|
||||
}
|
||||
|
||||
pub fn retry(&self) {
|
||||
self.state().error = None;
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<String> {
|
||||
let s = self.state();
|
||||
s.rejected.values().next().cloned().or_else(|| s.error.clone())
|
||||
}
|
||||
|
||||
pub fn unsaved(&self) -> bool {
|
||||
let s = self.state();
|
||||
s.writing || !s.dirty.is_empty() || !s.rejected.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PresetBackend for Bridge {
|
||||
fn list(&self) -> Result<Vec<(String, u64)>, String> {
|
||||
Ok(self.state().files.iter().map(|(k, b)| (k.clone(), b.len() as u64)).collect())
|
||||
}
|
||||
|
||||
fn read(&self, name: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
let s = self.state();
|
||||
let bytes = s.files.get(name).ok_or_else(|| format!("{name}: not found"))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(format!("{name}: preset record too large"));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let mut s = self.state();
|
||||
if s.files.get(name).is_some_and(|old| old.as_ref() == bytes) {
|
||||
s.rejected.remove(name);
|
||||
return Ok(());
|
||||
}
|
||||
if !s.dirty.contains(name) && s.dirty.len() >= MAX_RECORDS {
|
||||
let e = "too many pending preset records".to_string();
|
||||
s.rejected.insert(name.to_string(), e.clone());
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = Self::insert(&mut s, name, bytes) {
|
||||
s.rejected.insert(name.to_string(), e.clone());
|
||||
return Err(e);
|
||||
}
|
||||
s.dirty.insert(name.to_string());
|
||||
// This current replacement resolves only its own omitted edit. Pending transactions
|
||||
// still keep unsaved() true; Retry or an older successful batch cannot clear it.
|
||||
s.rejected.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
record_limit(name)?;
|
||||
let mut s = self.state();
|
||||
if s.preserve_tips && name.starts_with("tips/") {
|
||||
s.rejected.remove(name);
|
||||
return Ok(());
|
||||
}
|
||||
if !s.dirty.contains(name) && s.dirty.len() >= MAX_RECORDS {
|
||||
let e = "too many pending preset records".to_string();
|
||||
s.rejected.insert(name.to_string(), e.clone());
|
||||
return Err(e);
|
||||
}
|
||||
if let Some(bytes) = s.files.remove(name) {
|
||||
s.bytes = s.bytes.saturating_sub(bytes.len() as u64);
|
||||
s.dirty.insert(name.to_string());
|
||||
}
|
||||
s.rejected.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12};
|
||||
use serde_json::json;
|
||||
|
||||
fn import(s: &mut Session) {
|
||||
let abr = write_v12(
|
||||
2,
|
||||
&[LegacyBrush {
|
||||
name: "Browser seam brush".into(),
|
||||
spacing: 25,
|
||||
anti_alias: true,
|
||||
tip: LegacyTip::Sampled(AbrSample { id: String::new(), width: 6, height: 4, depth: 8, data: vec![255; 24] }),
|
||||
}],
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "group": "Browser samples"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_session_reloads_imported_tip_settings_group_and_order() {
|
||||
let backend = Bridge::default();
|
||||
let (mut first, _) = session(backend.clone());
|
||||
import(&mut first);
|
||||
let (second, warnings) = session(backend);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(first.tools.presets, second.tools.presets);
|
||||
}
|
||||
#[test]
|
||||
fn pending_writes_coalesce_and_only_one_transaction_runs() {
|
||||
let b = Bridge::default();
|
||||
b.write("index.json", b"one").unwrap();
|
||||
b.write("index.json", b"two").unwrap();
|
||||
let batch = b.begin().unwrap();
|
||||
assert_eq!(batch.get("index.json").unwrap().as_deref(), Some(&b"two"[..]));
|
||||
b.remove("index.json").unwrap();
|
||||
assert!(b.begin().is_none());
|
||||
assert!(b.unsaved());
|
||||
b.finish(batch, Ok(()));
|
||||
let batch = b.begin().unwrap();
|
||||
assert!(batch.get("index.json").unwrap().is_none());
|
||||
b.finish(batch, Ok(()));
|
||||
assert!(!b.unsaved());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transaction_retains_latest_writes_until_explicit_retry() {
|
||||
let b = Bridge::default();
|
||||
b.write("index.json", b"old").unwrap();
|
||||
let batch = b.begin().unwrap();
|
||||
b.write("index.json", b"new").unwrap();
|
||||
b.finish(batch, Err("QuotaExceededError".into()));
|
||||
assert!(b.unsaved());
|
||||
assert!(b.error().unwrap().contains("QuotaExceeded"));
|
||||
assert!(b.begin().is_none());
|
||||
b.retry();
|
||||
let batch = b.begin().unwrap();
|
||||
assert_eq!(batch.get("index.json").unwrap().as_deref(), Some(&b"new"[..]));
|
||||
b.finish(batch, Ok(()));
|
||||
assert!(!b.unsaved());
|
||||
assert!(b.error().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_deletion_and_reordering_survive_reload() {
|
||||
let b = Bridge::default();
|
||||
let (mut s, _) = session(b.clone());
|
||||
import(&mut s);
|
||||
let deleted = s.tools.presets[0].name.clone();
|
||||
s.execute("brush.presets.delete", json!({"name": deleted})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group":"Browser samples", "index":0})).unwrap();
|
||||
let persisted = b.begin().unwrap();
|
||||
let restored = Bridge::default();
|
||||
for (key, value) in &persisted {
|
||||
if let Some(bytes) = value {
|
||||
restored.load(key, bytes.to_vec()).unwrap();
|
||||
}
|
||||
}
|
||||
b.finish(persisted, Ok(()));
|
||||
let (loaded, warnings) = session(restored);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(s.tools.presets, loaded.tools.presets);
|
||||
s.execute("brush.presets.delete", json!({"name":"Browser seam brush"})).unwrap();
|
||||
let (loaded, _) = session(b);
|
||||
assert_eq!(s.tools.presets, loaded.tools.presets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_record_is_skipped_with_warning_and_kept_for_recovery() {
|
||||
let b = Bridge::default();
|
||||
b.load("bad.pcbrushes", b"corrupt".to_vec()).unwrap();
|
||||
let (s, warnings) = session(b.clone());
|
||||
assert_eq!(s.tools.presets, Session::new().tools.presets);
|
||||
assert!(warnings.iter().any(|w| w.contains("bad.pcbrushes")));
|
||||
assert_eq!(b.read("bad.pcbrushes", 100).unwrap(), b"corrupt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_and_oversized_records_before_copying() {
|
||||
assert!(record_limit("../index.json").is_err());
|
||||
assert!(record_limit("unknown.bin").is_err());
|
||||
assert_eq!(record_limit("index.json").unwrap(), MAX_INDEX_BYTES);
|
||||
assert_eq!(record_limit("tips/a.pctip").unwrap(), MAX_TIP_BYTES);
|
||||
let b = Bridge::default();
|
||||
b.load("index.json", b"initial".to_vec()).unwrap();
|
||||
assert!(!b.unsaved(), "hydration is not a user edit");
|
||||
assert!(b.read("index.json", 2).is_err());
|
||||
assert!(b.write("index.json", &vec![0; MAX_INDEX_BYTES as usize + 1]).is_err());
|
||||
assert!(b.error().is_some());
|
||||
b.retry();
|
||||
assert!(b.error().is_some(), "staging errors cannot be cleared by retrying older writes");
|
||||
assert_eq!(b.read("index.json", 100).unwrap(), b"initial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_replacement_resolves_only_its_own_staging_rejection() {
|
||||
let b = Bridge::default();
|
||||
b.load("index.json", b"initial".to_vec()).unwrap();
|
||||
let oversized = vec![0; MAX_INDEX_BYTES as usize + 1];
|
||||
assert!(b.write("index.json", &oversized).is_err());
|
||||
b.retry();
|
||||
assert!(b.error().is_some(), "retrying older bytes cannot save the rejected edit");
|
||||
b.write("index.json", b"repaired").unwrap();
|
||||
assert!(b.unsaved(), "accepted replacement still needs its transaction");
|
||||
b.finish(b.begin().unwrap(), Ok(()));
|
||||
assert!(b.error().is_none());
|
||||
assert!(!b.unsaved());
|
||||
assert_eq!(b.read("index.json", 100).unwrap(), b"repaired");
|
||||
|
||||
assert!(b.write("index.json", &oversized).is_err());
|
||||
assert!(b.write("other.pcbrushes", &vec![0; MAX_GROUP_BYTES as usize + 1]).is_err());
|
||||
b.write("index.json", b"repaired").unwrap();
|
||||
assert!(b.error().is_some(), "an unchanged accepted record cannot clear another record's rejection");
|
||||
assert!(b.unsaved());
|
||||
b.remove("other.pcbrushes").unwrap();
|
||||
assert!(b.error().is_none());
|
||||
assert!(!b.unsaved(), "discarding the rejected absent record resolves its pending edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_successful_transaction_does_not_resolve_an_omitted_edit() {
|
||||
let b = Bridge::default();
|
||||
b.write("index.json", b"older").unwrap();
|
||||
let older = b.begin().unwrap();
|
||||
assert!(b.write("index.json", &vec![0; MAX_INDEX_BYTES as usize + 1]).is_err());
|
||||
b.finish(older, Ok(()));
|
||||
assert!(b.error().is_some());
|
||||
assert!(b.unsaved());
|
||||
b.write("index.json", b"older").unwrap();
|
||||
assert!(b.error().is_none(), "explicitly restoring the already saved bytes resolves the omitted edit");
|
||||
assert!(!b.unsaved());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_storage_keeps_imports_in_memory_and_marks_them_unsaved() {
|
||||
let b = Bridge::default();
|
||||
b.unavailable("SecurityError".into());
|
||||
let (mut s, _) = session(b.clone());
|
||||
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
|
||||
import(&mut s);
|
||||
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
|
||||
assert!(b.unsaved());
|
||||
assert!(b.error().unwrap().contains("SecurityError"));
|
||||
assert!(b.begin().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_group_and_shared_tip_survive_unrelated_save_delete_and_reload() {
|
||||
let b = Bridge::default();
|
||||
let (mut s, _) = session(b.clone());
|
||||
import(&mut s);
|
||||
let tip = b.list().unwrap().into_iter().find(|(n, _)| n.starts_with("tips/")).unwrap().0;
|
||||
b.load("damaged.pcbrushes", b"recoverable broken group".to_vec()).unwrap();
|
||||
let (mut s, warnings) = session(b.clone());
|
||||
assert!(!warnings.is_empty());
|
||||
s.execute("brush.presets.save", json!({"name":"Unrelated brush"})).unwrap();
|
||||
s.execute("brush.presets.delete", json!({"name":"Browser seam brush"})).unwrap();
|
||||
assert_eq!(b.read("damaged.pcbrushes", 100).unwrap(), b"recoverable broken group");
|
||||
assert!(b.read(&tip, MAX_TIP_BYTES).is_ok());
|
||||
let (reloaded, warnings) = session(b.clone());
|
||||
assert!(!warnings.is_empty());
|
||||
assert!(reloaded.tools.presets.iter().any(|p| p.name == "Unrelated brush"));
|
||||
assert!(!reloaded.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
|
||||
assert_eq!(b.read("damaged.pcbrushes", 100).unwrap(), b"recoverable broken group");
|
||||
assert!(b.read(&tip, MAX_TIP_BYTES).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn indexed_brush_store_coexists_with_imported_gradient_preferences() {
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
|
||||
let b = Bridge::default();
|
||||
let (mut s, _) = session(b.clone());
|
||||
import(&mut s);
|
||||
let data = write(&[GrdGradient {
|
||||
name: "Browser gradient".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Foreground },
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.5, 0.5)],
|
||||
}]);
|
||||
s.execute("gradient.presets.importGrd", json!({"data":photocraft_paint::tile::b64_encode(&data),"group":"Browser gradients"})).unwrap();
|
||||
let prefs = s.prefs_to_json();
|
||||
let (mut loaded, _) = session(b);
|
||||
loaded.load_prefs_json(&prefs).unwrap();
|
||||
assert_eq!(loaded.tools.presets, s.tools.presets);
|
||||
assert_eq!(loaded.presets.gradients, s.presets.gradients);
|
||||
assert!(loaded.presets.gradients.iter().any(|g| g.name == "Browser gradients"));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,24 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_ui_egui::i18n;
|
||||
use photocraft_ui_egui::served_fonts;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::theme::ThemeKind;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Services};
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
|
||||
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
|
||||
/// format, each file relative to the site root. No manifest (a 404) means no served fonts.
|
||||
const FONTS_MANIFEST: &str = "fonts/manifest.txt";
|
||||
|
||||
/// The served fonts: the manifest once read, and what the network has delivered.
|
||||
#[derive(Default)]
|
||||
struct ServedFonts {
|
||||
/// Font files by family, each with its Subresource Integrity value when the manifest has one.
|
||||
files: HashMap<String, Vec<(String, Option<String>)>>,
|
||||
/// Families fetched or being fetched: each one once per page load.
|
||||
fetched: HashSet<String>,
|
||||
/// Downloaded files waiting for the next frame: (family, bytes).
|
||||
arrived: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
type Served = Arc<Mutex<ServedFonts>>;
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -61,16 +39,6 @@ pub fn start() {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
// Hydrate before constructing an interactive session: a late load must never undo a
|
||||
// user deletion/reorder. Storage denial or timeout still opens a usable session.
|
||||
let (database, presets, mut preset_warnings) = match crate::indexed_presets::load().await {
|
||||
Ok((db, bridge, warnings)) => (Some(db), bridge, warnings),
|
||||
Err(e) => {
|
||||
let bridge = crate::preset_bridge::Bridge::default();
|
||||
bridge.unavailable(e);
|
||||
(None, bridge, Vec::new())
|
||||
}
|
||||
};
|
||||
let pen_target = canvas.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
@@ -79,14 +47,7 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let 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()));
|
||||
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);
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
listen_pen(&pen_target, app.stylus.feed.clone());
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
@@ -95,9 +56,7 @@ pub fn start() {
|
||||
log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
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 }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -110,7 +69,7 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
|
||||
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
|
||||
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
|
||||
@@ -124,15 +83,7 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
|
||||
return;
|
||||
}
|
||||
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
|
||||
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
|
||||
let eraser = e.buttons() & 32 != 0;
|
||||
feed.set(pen.then(|| PenSample {
|
||||
pressure: e.pressure(),
|
||||
tilt_x: e.tilt_x() as f32,
|
||||
tilt_y: e.tilt_y() as f32,
|
||||
rotation: e.twist() as f32,
|
||||
eraser,
|
||||
}));
|
||||
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
|
||||
});
|
||||
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
@@ -140,146 +91,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
fn guard_unload(unsaved: Arc<AtomicBool>) {
|
||||
use wasm_bindgen::closure::Closure;
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let cb = Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
|
||||
if unsaved.load(Ordering::Relaxed) {
|
||||
e.prevent_default();
|
||||
// Older browsers show the prompt only when a return value is set.
|
||||
e.set_return_value("");
|
||||
}
|
||||
});
|
||||
if window.add_event_listener_with_callback("beforeunload", cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reads the served font manifest, if the host has one, and lists its families in the font menus.
|
||||
fn load_font_manifest(served: Served, ctx: egui::Context) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Most hosts serve no fonts: a missing manifest is the normal case, not an error.
|
||||
let Ok(bytes) = fetch_bytes(FONTS_MANIFEST, None).await else { return };
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
// A host that answers unknown paths with its HTML page (single-page app fallback).
|
||||
if text.trim_start().starts_with('<') {
|
||||
return;
|
||||
}
|
||||
let (fonts, skipped) = served_fonts::parse_manifest(&text);
|
||||
for s in skipped {
|
||||
log::warn!("{FONTS_MANIFEST}: skipped {s}");
|
||||
}
|
||||
let mut files: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
|
||||
for f in &fonts {
|
||||
files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone()));
|
||||
}
|
||||
log::info!("photocraft-web: {} served font families", files.len());
|
||||
served.lock().unwrap_or_else(|e| e.into_inner()).files = files;
|
||||
// The families for the font menus, and the script fallbacks (the manifest's `scripts`).
|
||||
served_fonts::add_fonts(&fonts);
|
||||
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
|
||||
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
|
||||
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// The browser's preferred languages (`navigator.languages`, e.g. `["ar-EG", "en"]`).
|
||||
fn browser_languages() -> Vec<String> {
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
|
||||
/// Integrity value), the browser rejects a body whose hash differs.
|
||||
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let request = match integrity {
|
||||
Some(sri) => {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_integrity(sri);
|
||||
window.fetch_with_str_and_init(url, &init)
|
||||
}
|
||||
None => window.fetch_with_str(url),
|
||||
};
|
||||
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let body = JsFuture::from(resp.array_buffer().map_err(js)?).await.map_err(js)?;
|
||||
Ok(js_sys::Uint8Array::new(&body).to_vec())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
struct WebShell {
|
||||
app: PhotocraftApp,
|
||||
inbox: Inbox,
|
||||
/// Read by the `beforeunload` listener ([`guard_unload`]).
|
||||
unsaved: Arc<AtomicBool>,
|
||||
served: Served,
|
||||
database: Option<web_sys::IdbDatabase>,
|
||||
presets: crate::preset_bridge::Bridge,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
fn save_presets(&mut self, ctx: &egui::Context) {
|
||||
if let Some(db) = self.database.clone()
|
||||
&& let Some(batch) = self.presets.begin()
|
||||
{
|
||||
let presets = self.presets.clone();
|
||||
let wake = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = crate::indexed_presets::write(&db, &batch).await;
|
||||
if let Err(e) = &result {
|
||||
log::error!("Could not save browser presets: {e}");
|
||||
}
|
||||
presets.finish(batch, result);
|
||||
wake.request_repaint();
|
||||
});
|
||||
}
|
||||
self.unsaved.store(self.app.has_unsaved_work() || self.presets.unsaved(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
|
||||
/// installs the files that arrived.
|
||||
fn serve_fonts(&mut self, ctx: &egui::Context) {
|
||||
let requested = served_fonts::take_requests();
|
||||
let (fetch, arrived) = {
|
||||
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut fetch = Vec::new();
|
||||
for family in requested {
|
||||
if let Some(files) = s.files.get(&family).cloned()
|
||||
&& s.fetched.insert(family.clone())
|
||||
{
|
||||
fetch.extend(files.into_iter().map(|(file, integrity)| (family.clone(), file, integrity)));
|
||||
}
|
||||
}
|
||||
(fetch, std::mem::take(&mut s.arrived))
|
||||
};
|
||||
for (family, file, integrity) in fetch {
|
||||
let served = self.served.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match fetch_bytes(&file, integrity.as_deref()).await {
|
||||
Ok(bytes) => {
|
||||
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
served_fonts::install(&mut self.app, arrived);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -299,84 +119,42 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.serve_fonts(ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.save_presets(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
// Only while a preset change is unsaved. Storage the browser denies from the start (a
|
||||
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
|
||||
// change brings the banner with the reason.
|
||||
if self.presets.unsaved()
|
||||
&& let Some(error) = self.presets.error()
|
||||
{
|
||||
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
|
||||
self.presets.retry();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
|
||||
});
|
||||
ui.label(if self.database.is_some() {
|
||||
i18n::t("Keep this tab open to retain your brushes.")
|
||||
} else {
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
});
|
||||
}
|
||||
self.app.ui(ui, frame);
|
||||
self.save_presets(ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
// The browser's file picker hands over the files' contents, not paths. File › Open
|
||||
// asks for several files (`multiple`), each opened in its own tab.
|
||||
FileDialogRequest::Open { multiple, extensions, .. } => wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = if let Some(exts) = extensions {
|
||||
rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts)
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS)
|
||||
};
|
||||
let answer = if multiple {
|
||||
match dialog.pick_files().await {
|
||||
Some(picked) if !picked.is_empty() => {
|
||||
let mut files = Vec::with_capacity(picked.len());
|
||||
for file in picked {
|
||||
files.push((file.file_name(), file.read().await));
|
||||
}
|
||||
Some(FileDialogAnswer::ContentsMany(files))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
match dialog.pick_file().await {
|
||||
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
reply.send(answer);
|
||||
}),
|
||||
// Browser folder handles cannot be represented as filesystem paths for engine commands.
|
||||
FileDialogRequest::Folder { .. } => reply.send(None),
|
||||
// No save dialog on the web: the suggested name becomes the download name.
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
|
||||
reply.send(Some(FileDialogAnswer::SaveTo(name)));
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
None
|
||||
})),
|
||||
// No save dialog on the web: the suggested name becomes the download name.
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
|
||||
Some(name)
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
@@ -432,77 +210,6 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("webp") => "image/webp",
|
||||
Some("gif") => "image/gif",
|
||||
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
|
||||
Some("ora") => "image/openraster",
|
||||
Some("kra" | "krz") => "application/x-krita",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust bindings to the browser's user-activated EyeDropper API; unsupported browsers keep
|
||||
/// the document picker and its normal canvas zoom. The browser owns its screen magnifier.
|
||||
fn screen_color_service() -> Option<photocraft_ui_egui::screen_picker::Service> {
|
||||
use js_sys::{Function, Reflect};
|
||||
use photocraft_ui_egui::screen_picker::{Capture, Pending};
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
let window = web_sys::window()?;
|
||||
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
|
||||
Some(Box::new(move |ctx| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let started = (|| -> Result<_, String> {
|
||||
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(|e| format!("Could not start browser eyedropper: {e:?}"))?;
|
||||
let controller = web_sys::AbortController::new().map_err(|e| format!("Could not create eyedropper cancellation: {e:?}"))?;
|
||||
let options = js_sys::Object::new();
|
||||
Reflect::set(&options, &JsValue::from_str("signal"), &controller.signal()).map_err(|e| format!("Could not set eyedropper options: {e:?}"))?;
|
||||
let open = Reflect::get(&eye, &JsValue::from_str("open"))
|
||||
.map_err(|e| format!("Browser eyedropper has no open method: {e:?}"))?
|
||||
.dyn_into::<Function>()
|
||||
.map_err(|_| "Invalid browser eyedropper method")?;
|
||||
let promise = open
|
||||
.call1(&eye, &options)
|
||||
.map_err(|e| format!("Could not open browser eyedropper: {e:?}"))?
|
||||
.dyn_into::<js_sys::Promise>()
|
||||
.map_err(|_| "Browser eyedropper returned no promise")?;
|
||||
let stop = cancelled.clone();
|
||||
let abort = controller.clone();
|
||||
let timer = Closure::<dyn FnMut()>::new(move || {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
let interval = window.set_interval_with_callback_and_timeout_and_arguments_0(timer.as_ref().unchecked_ref(), 50).map_err(|e| {
|
||||
controller.abort();
|
||||
format!("Could not watch eyedropper cancellation: {e:?}")
|
||||
})?;
|
||||
Ok((promise, timer, interval))
|
||||
})();
|
||||
match started {
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e));
|
||||
}
|
||||
Ok((promise, timer, interval)) => {
|
||||
let wake = ctx.clone();
|
||||
let window = window.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(value) => Reflect::get(&value, &JsValue::from_str("sRGBHex"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.and_then(|h| photocraft_ui_egui::color_picker_ui::parse_hex(&h))
|
||||
.map(|rgb| Capture::Color(Some(rgb)))
|
||||
.ok_or_else(|| "Browser eyedropper returned an invalid color".into()),
|
||||
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).as_deref() == Some("AbortError") => {
|
||||
Ok(Capture::Color(None))
|
||||
}
|
||||
Err(e) => Err(format!("Browser screen color selection failed: {e:?}")),
|
||||
};
|
||||
window.clear_interval_with_handle(interval);
|
||||
drop(timer);
|
||||
let _ = tx.send(result);
|
||||
wake.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
Pending { receiver: rx, cancelled }
|
||||
}))
|
||||
}
|
||||
@@ -7,11 +7,6 @@ default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["photocraft-codecs/heif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -27,76 +22,21 @@ photocraft-io = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
arboard = "3"
|
||||
open = "5"
|
||||
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
|
||||
ron = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
|
||||
# `event` (CGEventSource) is used for the Caps Lock poll (#1758).
|
||||
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link", "event"] }
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
|
||||
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.4"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
|
||||
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
|
||||
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
|
||||
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
|
||||
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 }
|
||||
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.
|
||||
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
|
||||
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
|
||||
resvg = { version = "=0.45.1", default-features = false }
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
|
||||
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
|
||||
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
|
||||
|
||||
# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`,
|
||||
# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles),
|
||||
# no dependencies of its own. Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
|
||||
xcap = "=0.9.8"
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
|
||||
[[test]]
|
||||
name = "mac_menu_appkit"
|
||||
harness = false
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
|
||||
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
|
||||
//! everything in `<exe dir>/PhotoCraftData`, so a portable copy on a USB stick leaves nothing in
|
||||
//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that
|
||||
//! 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`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "PhotoCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
|
||||
|
||||
/// How [`DataDir::dir`] was chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `PHOTOCRAFT_CONFIG_DIR`.
|
||||
Override,
|
||||
/// `<exe dir>/PhotoCraftData`.
|
||||
Portable,
|
||||
/// The platform's per-user config directory.
|
||||
Platform,
|
||||
}
|
||||
|
||||
/// The resolved data directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DataDir {
|
||||
/// `None` only when no platform directory is known either (no `HOME`/`APPDATA`).
|
||||
pub dir: Option<PathBuf>,
|
||||
pub mode: Mode,
|
||||
/// Set when a portable marker was found but its data folder isn't writable.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// The data directory for this process, resolved once (the portable check probes the disk).
|
||||
pub fn current() -> &'static DataDir {
|
||||
static DIR: OnceLock<DataDir> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent));
|
||||
if let Some(w) = &d.warning {
|
||||
log::warn!("{w}");
|
||||
eprintln!("photocraft: {w}");
|
||||
}
|
||||
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-user settings directory (see the module docs).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
current().dir.clone()
|
||||
}
|
||||
|
||||
/// Resolve the data directory from the environment (`env`) and the executable's folder.
|
||||
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
|
||||
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None };
|
||||
}
|
||||
let platform = platform_dir(&env);
|
||||
let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else {
|
||||
return DataDir { dir: platform, mode: Mode::Platform, warning: None };
|
||||
};
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match ensure_writable(&data) {
|
||||
Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None },
|
||||
Err(e) => {
|
||||
let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string());
|
||||
let warning =
|
||||
format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display());
|
||||
DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`).
|
||||
pub fn platform_dir(env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
}
|
||||
|
||||
/// The first portable marker file in `exe_dir`, if any.
|
||||
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
|
||||
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let m: HashMap<String, OsString> = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect();
|
||||
move |k| m.get(k).cloned()
|
||||
}
|
||||
|
||||
/// A fake user folder that every platform's convention resolves under.
|
||||
fn fake_home(root: &Path) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let home = root.join("home");
|
||||
let appdata = root.join("appdata");
|
||||
env_of(&[("HOME", &home), ("APPDATA", &appdata)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_platform_dir() {
|
||||
let root = temp("platform");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(Some(dir.clone()), platform_dir(&env));
|
||||
assert!(!dir.starts_with(&exe));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
|
||||
// The convention per platform.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
|
||||
} else if cfg!(windows) {
|
||||
assert_eq!(dir, root.join("appdata").join("Photocraft"));
|
||||
} else {
|
||||
assert_eq!(dir, root.join("home/.config/photocraft"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_keeps_everything_beside_the_exe() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let root = temp("portable");
|
||||
let exe = root.join("PhotoCraft");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let d = resolve(fake_home(&root), Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Portable, "{marker}");
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR));
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
// Every derived path sits under the exe dir.
|
||||
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
|
||||
assert!(dir.join(sub).starts_with(&exe));
|
||||
}
|
||||
assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marker_folder_is_not_a_marker() {
|
||||
let root = temp("dir-marker");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_portable_folder_falls_back_with_a_warning() {
|
||||
let root = temp("unwritable");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert_eq!(d.dir, platform_dir(&env));
|
||||
let w = d.warning.unwrap();
|
||||
assert!(w.contains("portable") && w.contains("isn't writable"), "{w}");
|
||||
// No user folder at all: still no panic, just no directory.
|
||||
let d = resolve(|_| None, Some(&exe));
|
||||
assert!(d.dir.is_none() && d.warning.is_some());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_wins_over_portable() {
|
||||
let root = temp("override");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let custom = root.join("custom");
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &custom)]), Some(&exe));
|
||||
assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom)));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exe_dir_or_env_never_panics() {
|
||||
let d = resolve(|_| None, None);
|
||||
assert_eq!((d.mode, d.dir), (Mode::Platform, None));
|
||||
let empty = PathBuf::new();
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
|
||||
assert_eq!(d.dir, None, "empty variables are ignored");
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! The app icon, everywhere Windows, macOS and Linux show one (#248).
|
||||
//!
|
||||
//! - **Executable:** `build.rs` embeds `assets/app-icon/photocraft.ico` (16–256 px) as icon
|
||||
//! resource 1, which Explorer, Alt+Tab and an unpinned taskbar button fall back to.
|
||||
//! - **Window:** [`window_icon`] sets the title-bar and taskbar icon at runtime (`WM_SETICON` on
|
||||
//! Windows, `_NET_WM_ICON` on X11, the Dock when running unbundled on macOS).
|
||||
//! - **Start Menu shortcut (MSI):** the taskbar matches a running window to the shortcut that
|
||||
//! launches its executable and shows that shortcut's icon. Windows Installer caches an advertised
|
||||
//! shortcut's icon under its `<Icon Id>`, so that id must carry the target's `.exe` extension
|
||||
//! (ICE50); an extensionless id showed a blank page on the taskbar. The tests below check it.
|
||||
//!
|
||||
//! The process keeps Windows' implicit AppUserModelID (derived from the executable path), which
|
||||
//! is what makes that matching work for both the MSI shortcut and the portable exe. An explicit
|
||||
//! ID would need a Win32 call (`unsafe`, which the workspace forbids) and would then also have to
|
||||
//! be stamped on the shortcut, or the taskbar would stop matching the two.
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
pub fn window_icon() -> egui::IconData {
|
||||
match eframe::icon_data::from_png_bytes(PNG) {
|
||||
Ok(icon) => icon,
|
||||
Err(e) => {
|
||||
log::warn!("app icon: {e}");
|
||||
egui::IconData::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ICO: &[u8] = include_bytes!("../../../assets/app-icon/photocraft.ico");
|
||||
const WXS: &str = include_str!("../../../packaging/windows/photocraft.wxs");
|
||||
|
||||
fn u16_at(b: &[u8], i: usize) -> usize {
|
||||
u16::from_le_bytes([b[i], b[i + 1]]) as usize
|
||||
}
|
||||
fn u32_at(b: &[u8], i: usize) -> usize {
|
||||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
assert!(icon.width >= 256 && icon.width == icon.height, "{}x{}", icon.width, icon.height);
|
||||
assert_eq!(icon.rgba.len(), icon.width as usize * icon.height as usize * 4);
|
||||
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
|
||||
}
|
||||
|
||||
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
|
||||
#[test]
|
||||
fn exe_icon_has_every_shell_size() {
|
||||
assert_eq!((u16_at(ICO, 0), u16_at(ICO, 2)), (0, 1), "ICONDIR header, type 1 (icon)");
|
||||
let n = u16_at(ICO, 4);
|
||||
let mut sizes = Vec::new();
|
||||
for i in 0..n {
|
||||
let e = 6 + i * 16;
|
||||
let w = if ICO[e] == 0 { 256 } else { ICO[e] as usize };
|
||||
let h = if ICO[e + 1] == 0 { 256 } else { ICO[e + 1] as usize };
|
||||
assert_eq!(w, h);
|
||||
let (len, off) = (u32_at(ICO, e + 8), u32_at(ICO, e + 12));
|
||||
let data = ICO.get(off..off + len).expect("image data inside the file");
|
||||
let png = data.starts_with(b"\x89PNG\r\n\x1a\n");
|
||||
let bmp = data.len() >= 40 && u32_at(data, 0) == 40;
|
||||
assert!(png || bmp, "{w}px entry is neither PNG nor BMP");
|
||||
if png {
|
||||
// IHDR width/height match the directory entry.
|
||||
let pw = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
assert_eq!(pw, w, "{w}px entry holds a {pw}px PNG");
|
||||
}
|
||||
sizes.push(w);
|
||||
}
|
||||
for want in [16, 24, 32, 48, 64, 256] {
|
||||
assert!(sizes.contains(&want), "photocraft.ico lacks {want}px (has {sizes:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `attr="…"` inside `element`.
|
||||
fn attr<'a>(element: &'a str, attr: &str) -> Option<&'a str> {
|
||||
let key = format!(" {attr}=\"");
|
||||
let start = element.find(&key)? + key.len();
|
||||
element[start..].split('"').next()
|
||||
}
|
||||
|
||||
/// Each `<tag …>` element's text up to its closing `>`.
|
||||
fn elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
|
||||
let open = format!("<{tag} ");
|
||||
xml.match_indices(&open).map(|(i, _)| &xml[i..i + xml[i..].find('>').unwrap()]).collect()
|
||||
}
|
||||
|
||||
/// ICE50 (#248): advertised shortcut icons are cached under their `<Icon Id>`; without the
|
||||
/// target's extension the shell shows a blank page on the Start Menu and the taskbar.
|
||||
#[test]
|
||||
fn msi_shortcut_icons_keep_the_exe_extension() {
|
||||
let icons: Vec<&str> = elements(WXS, "Icon").iter().filter_map(|e| attr(e, "Id")).collect();
|
||||
assert!(!icons.is_empty());
|
||||
for id in &icons {
|
||||
assert!(id.ends_with(".exe") || id.ends_with(".ico"), "<Icon Id=\"{id}\"> needs an .exe or .ico extension");
|
||||
}
|
||||
let shortcuts = elements(WXS, "Shortcut");
|
||||
assert!(!shortcuts.is_empty());
|
||||
for s in shortcuts {
|
||||
let icon = attr(s, "Icon").expect("shortcuts name their icon");
|
||||
assert!(icons.contains(&icon), "shortcut icon {icon} isn't an <Icon>");
|
||||
if attr(s, "Advertise") == Some("yes") {
|
||||
// Every shortcut in the MSI targets photocraft.exe.
|
||||
assert!(icon.ends_with(".exe"), "advertised shortcut icon {icon} must end in .exe like its target (ICE50)");
|
||||
}
|
||||
}
|
||||
let arp = WXS.split("Id=\"ARPPRODUCTICON\" Value=\"").nth(1).and_then(|r| r.split('"').next()).expect("ARPPRODUCTICON");
|
||||
assert!(icons.contains(&arp), "ARPPRODUCTICON {arp} isn't an <Icon>");
|
||||
// The Start Menu shortcut must not set its own AppUserModelID: the process keeps the
|
||||
// implicit one, and a mismatch would split the taskbar button from the shortcut.
|
||||
assert!(!WXS.contains("System.AppUserModel.ID"));
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
//! Linux desktop colour scheme, including Wayland compositors that do not report a winit theme.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
portal_code(value).and_then(decode).or_else(gtk_theme)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
// The Settings.Read result is a variant; some portals nest another variant inside it.
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
|
||||
/// using an absolute path rather than the shell's PATH.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" | "default" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_startup<T>(subscribe: impl FnOnce(), read: impl FnOnce() -> T) -> T {
|
||||
subscribe();
|
||||
read()
|
||||
}
|
||||
|
||||
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
|
||||
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
|
||||
pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
start(|publisher| {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let mut signals = None;
|
||||
let initial = portal_startup(
|
||||
|| signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()),
|
||||
|| proxy.as_ref().and_then(portal_theme).or_else(gtk_theme),
|
||||
);
|
||||
publisher.publish(initial);
|
||||
let Some(signals) = signals else { return };
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker's handle on the shared value. Every change wakes the registered UI, including the
|
||||
/// first read when it lands after the start-up wait below.
|
||||
#[cfg(target_os = "linux")]
|
||||
struct Publisher {
|
||||
value: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
wake: std::sync::Arc<std::sync::OnceLock<egui::Context>>,
|
||||
ready: std::sync::mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Publisher {
|
||||
fn publish(&self, theme: Option<egui::Theme>) {
|
||||
let code = code_of(theme);
|
||||
if self.value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = self.wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let _ = self.ready.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `worker` on its own thread and waits up to 250 ms for its first value, so a fast desktop
|
||||
/// answers before the first frame.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn start(worker: impl FnOnce(&Publisher) + Send + 'static) -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
use std::sync::{Arc, OnceLock, atomic::AtomicU8};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake), ready };
|
||||
std::thread::Builder::new().name("appearance-portal".into()).spawn(move || worker(&publisher)).ok()?;
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(std::sync::atomic::Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn startup_keeps_changes_emitted_during_initial_read() {
|
||||
use std::cell::Cell;
|
||||
|
||||
for (initial_value, changed_value) in [(2, 1), (1, 2), (2, 2)] {
|
||||
let subscribed = Cell::new(false);
|
||||
let mut portal_value = initial_value;
|
||||
let mut pending_signal = None;
|
||||
let initial = super::portal_startup(
|
||||
|| subscribed.set(true),
|
||||
|| {
|
||||
let initial = portal_value;
|
||||
portal_value = changed_value;
|
||||
if subscribed.get() && portal_value != initial {
|
||||
pending_signal = Some(portal_value);
|
||||
}
|
||||
initial
|
||||
},
|
||||
);
|
||||
let cached = pending_signal.unwrap_or(initial);
|
||||
assert_eq!(cached, changed_value, "portal changed during startup but cached {cached}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_scheme_values() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
// GNOME's "default" appearance is light, even though the portal reports no preference.
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
|
||||
}
|
||||
|
||||
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn late_initial_read_requests_repaint() {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
let (release, gate) = std::sync::mpsc::channel::<()>();
|
||||
let (done, finished) = std::sync::mpsc::channel::<()>();
|
||||
let reader = super::start(move |publisher| {
|
||||
let _ = gate.recv();
|
||||
publisher.publish(Some(egui::Theme::Light));
|
||||
let _ = done.send(());
|
||||
})
|
||||
.expect("worker thread");
|
||||
let ctx = egui::Context::default();
|
||||
let repaints = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&repaints);
|
||||
ctx.set_request_repaint_callback(move |_| {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
assert_eq!(reader(&ctx), None, "start-up wait elapsed without an answer");
|
||||
release.send(()).unwrap();
|
||||
finished.recv().unwrap();
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(reader(&ctx), Some(egui::Theme::Light));
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Caps Lock state, read from the OS once per frame so the canvas can show the precise crosshair
|
||||
//! for painting tools whatever the cursor preference (Photoshop's ⇪ behaviour, #1758).
|
||||
//!
|
||||
//! winit and egui have no Caps Lock key or modifier — `egui::Key` has no variant, `Modifiers` no
|
||||
//! field, and egui-winit's `key_from_named_key` drops the `NamedKey::CapsLock` key event before it
|
||||
//! reaches the app. So the state is polled here instead, from the platform:
|
||||
//!
|
||||
//! - **X11 (and XWayland)**: XKB's Lock modifier state, via the pure-Rust x11rb client. The app
|
||||
//! already opens on X11 when the `Preferences › Performance › Linux display server` preference
|
||||
//! asks for it, and on Wayland through XWayland for pen support, so this is the common path.
|
||||
//! - **Windows**: `GetKeyState(VK_CAPITAL)` through the safe `winsafe` binding.
|
||||
//! - **macOS**: `CGEventSourceFlagsState` (`kCGEventFlagAlphaShift`) through `core-graphics2`.
|
||||
//! - **Native Wayland**: there is no global keyboard-state query (no equivalent of XKB GetState or
|
||||
//! VK_CAPITAL); the service is `None` there and Caps Lock leaves the cursor preference alone.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use core_graphics2::event_source::CGEventSource;
|
||||
use core_graphics2::event_types::{CGEventFlags, CGEventSourceStateID};
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
Some(Box::new(|| {
|
||||
let flags = CGEventSource::flags_state(CGEventSourceStateID::CombinedSessionState);
|
||||
flags.contains(CGEventFlags::MaskAlphaShift)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
Some(Box::new(|| {
|
||||
// The second element of the pair is "key toggled on", which is Caps Lock's state.
|
||||
let (_, toggled) = winsafe::GetKeyState(winsafe::co::VK::CAPITAL);
|
||||
toggled
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
use x11rb::protocol::xkb::{self, ConnectionExt as _};
|
||||
use x11rb::protocol::xproto::ModMask;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
// XKB keyboard state lives on the X server. The window is our window: whether it is X11
|
||||
// (or XWayland) is whether there is an X server to ask. Native Wayland has no such
|
||||
// connection and degrades to the cursor preference (no service).
|
||||
let is_x11 = matches!(cc.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_));
|
||||
if !is_x11 {
|
||||
return None;
|
||||
}
|
||||
// Lazily opened on first use and kept: the Lock-modifier mask is stable per keymap, so it
|
||||
// is queried once and only the toggle state is re-read every frame.
|
||||
let mut conn: Option<(RustConnection, ModMask)> = None;
|
||||
// After a failure, wait before reconnecting (and warn once per failure), so a broken X
|
||||
// connection doesn't reconnect and log on every frame.
|
||||
let mut retry_at: Option<std::time::Instant> = None;
|
||||
Some(Box::new(move || {
|
||||
if retry_at.is_some_and(|t| std::time::Instant::now() < t) {
|
||||
return false;
|
||||
}
|
||||
match caps_lock_on(&mut conn) {
|
||||
Ok(on) => {
|
||||
retry_at = None;
|
||||
on
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("couldn't read the Caps Lock state: {error}");
|
||||
conn = None;
|
||||
retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
|
||||
false
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Caps Lock is on when the Lock modifier is both engaged (`locked_mods`) and present in the
|
||||
/// keyboard's indicator map (so a re-mapped lock key is still recognised).
|
||||
fn caps_lock_on(conn: &mut Option<(RustConnection, ModMask)>) -> Result<bool, String> {
|
||||
if conn.is_none() {
|
||||
let (c, _) = RustConnection::connect(None).map_err(|e| e.to_string())?;
|
||||
// XKB requests need the extension active first (it is, on every modern server).
|
||||
let enabled = c.xkb_use_extension(1, 0).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
if !enabled.supported {
|
||||
return Err("the X server has no XKB extension".into());
|
||||
}
|
||||
let device = u16::from(xkb::ID::USE_CORE_KBD);
|
||||
// Indicator 0 is the Caps Lock indicator; its `.mods` is the Lock-modifier mask.
|
||||
let map = c.xkb_get_indicator_map(device, 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
let lock = map.maps.first().map_or(ModMask::LOCK, |m| m.mods);
|
||||
*conn = Some((c, lock));
|
||||
}
|
||||
let (conn, lock) = match conn.as_ref() {
|
||||
Some((conn, lock)) => (conn, *lock),
|
||||
None => return Err("no X connection".into()),
|
||||
};
|
||||
let device = u16::from(xkb::ID::USE_CORE_KBD);
|
||||
let state = conn.xkb_get_state(device).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
Ok(u16::from(state.locked_mods) & u16::from(lock) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
|
||||
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
|
||||
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
|
||||
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
|
||||
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
|
||||
//!
|
||||
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
|
||||
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Desktop {
|
||||
Gnome,
|
||||
Kde,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
|
||||
/// the like count as GNOME.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
|
||||
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
|
||||
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
return if v.contains("gnome") {
|
||||
Desktop::Gnome
|
||||
} else if v.contains("kde") || v.contains("plasma") {
|
||||
Desktop::Kde
|
||||
} else {
|
||||
Desktop::Other
|
||||
};
|
||||
}
|
||||
if var("KDE_FULL_SESSION").is_some() {
|
||||
Desktop::Kde
|
||||
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
|
||||
Desktop::Gnome
|
||||
} else {
|
||||
Desktop::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
|
||||
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
|
||||
match desktop {
|
||||
Desktop::Gnome => CaptionFamily::Adwaita,
|
||||
Desktop::Kde => CaptionFamily::Breeze,
|
||||
Desktop::Other => {
|
||||
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
|
||||
CaptionFamily::Adwaita
|
||||
} else if theme.starts_with("breeze") {
|
||||
CaptionFamily::Breeze
|
||||
} else {
|
||||
CaptionFamily::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
|
||||
let mut inside = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name == section;
|
||||
} else if inside
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
|
||||
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
|
||||
/// defaults: the window menu on the left, minimize, maximize and close on the right.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn kwin_layout(kwinrc: &str) -> String {
|
||||
let side = |key: &str, default: &str| {
|
||||
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
|
||||
letters
|
||||
.chars()
|
||||
.filter_map(|c| match c {
|
||||
'I' => Some("minimize"),
|
||||
'A' => Some("maximize"),
|
||||
'X' => Some("close"),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
|
||||
}
|
||||
|
||||
/// What the style is worked out from.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Default)]
|
||||
struct Found {
|
||||
gtk_theme: Option<String>,
|
||||
/// A GTK decoration layout.
|
||||
layout: Option<String>,
|
||||
icon_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
|
||||
/// the desktop's setting, or its default theme.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
match var("PHOTOCRAFT_CAPTION_ICONS") {
|
||||
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
|
||||
Some(name) => Some(name.trim().to_string()),
|
||||
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The style from what was found; `icons` loads a theme's glyphs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
|
||||
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
|
||||
// GNOME's own default shows Close only; GTK's shows all three.
|
||||
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
|
||||
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
|
||||
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
|
||||
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
|
||||
style
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_text(path: &std::path::Path) -> Option<String> {
|
||||
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
|
||||
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
|
||||
let config = var("XDG_CONFIG_HOME")
|
||||
.filter(|d| std::path::Path::new(d).is_absolute())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
|
||||
let Some(config) = config else { return Found::default() };
|
||||
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
|
||||
let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name");
|
||||
if desktop == Desktop::Kde {
|
||||
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
|
||||
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
|
||||
Found { gtk_theme, layout, icon_theme }
|
||||
} else {
|
||||
let layout = ini_value(>k, "Settings", "gtk-decoration-layout");
|
||||
Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
|
||||
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn load_icons(theme: &str) -> Option<CaptionIcons> {
|
||||
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
|
||||
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
|
||||
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
|
||||
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
|
||||
if files.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let render = move |icon: CaptionIcon, px: u32| {
|
||||
let i = match icon {
|
||||
CaptionIcon::Minimize => 0,
|
||||
CaptionIcon::Maximize => 1,
|
||||
CaptionIcon::Restore => 2,
|
||||
CaptionIcon::Close => 3,
|
||||
};
|
||||
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
|
||||
};
|
||||
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
|
||||
}
|
||||
|
||||
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl IconCache {
|
||||
fn get(&self, theme: &str) -> Option<CaptionIcons> {
|
||||
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((name, icons)) = last.as_ref()
|
||||
&& name == theme
|
||||
{
|
||||
return icons.clone();
|
||||
}
|
||||
let icons = load_icons(theme);
|
||||
*last = Some((theme.to_string(), icons.clone()));
|
||||
icons
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
|
||||
#[cfg(target_os = "linux")]
|
||||
const LAYOUT_KEY: &str = "button-layout";
|
||||
#[cfg(target_os = "linux")]
|
||||
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICONS_KEY: &str = "icon-theme";
|
||||
|
||||
/// A string setting from the portal, unwrapping the variants some portals nest it in.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
|
||||
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
|
||||
/// work and wakes the UI only when the style changes; the first answer usually lands before the
|
||||
/// first frame.
|
||||
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let desktop = desktop(&var);
|
||||
let mut found = from_files(desktop, &var);
|
||||
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
|
||||
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake));
|
||||
let cache = IconCache::default();
|
||||
let publish = move |found: &Found| {
|
||||
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
|
||||
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *cur != style {
|
||||
*cur = style;
|
||||
if let Some(ctx) = waker.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
|
||||
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
|
||||
let Some(proxy) = proxy else {
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
return;
|
||||
};
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let signals = proxy.receive_signal("SettingChanged").ok();
|
||||
let read = |namespace: &str, key: &str| {
|
||||
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
|
||||
value.and_then(portal_string)
|
||||
};
|
||||
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
|
||||
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
for message in signals.into_iter().flatten() {
|
||||
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
|
||||
match (namespace.as_str(), key.as_str()) {
|
||||
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
|
||||
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
|
||||
_ => continue,
|
||||
}
|
||||
publish(&found);
|
||||
}
|
||||
});
|
||||
// A fast desktop answers before the first frame; a slow one updates the bar later.
|
||||
if spawned.is_ok() {
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
|
||||
}
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
|
||||
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_comes_from_the_first_variable_set() {
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[])), Desktop::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_follows_the_desktop_then_the_gtk_theme() {
|
||||
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
|
||||
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kwin_button_letters_become_a_gtk_layout() {
|
||||
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
|
||||
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
|
||||
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
|
||||
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
|
||||
assert_eq!(ini_value(rc, "nope", "library"), None);
|
||||
// Garbage is just no buttons on that side, never a panic.
|
||||
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
|
||||
}
|
||||
|
||||
fn no_icons(_: &str) -> Option<CaptionIcons> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
|
||||
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
|
||||
// The style carries what the loader returns for that name.
|
||||
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
|
||||
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
|
||||
});
|
||||
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_and_overrides_resolve_to_a_style() {
|
||||
let none = env(&[]);
|
||||
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
|
||||
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
|
||||
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
|
||||
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
|
||||
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
|
||||
let other = resolve(Desktop::Other, &found, &none, &no_icons);
|
||||
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
|
||||
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
|
||||
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
|
||||
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
|
||||
// An unknown override name falls back to what was found.
|
||||
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_strings_unwrap_nested_variants() {
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_files_are_read_from_the_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
|
||||
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
|
||||
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
|
||||
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
|
||||
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
|
||||
let other = from_files(Desktop::Other, &var);
|
||||
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
|
||||
let kde = from_files(Desktop::Kde, &var);
|
||||
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
|
||||
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
|
||||
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,13 @@ use std::io::{BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::REPLY_DEADLINE;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
@@ -79,33 +78,23 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(reply) = repeat_auth_reply(&line) {
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let deadline = Instant::now() + REPLY_DEADLINE;
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
let dispatch = req.dispatch.clone();
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r =
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if write_reply(&mut out, &reply).is_err() {
|
||||
break;
|
||||
@@ -120,7 +109,6 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
@@ -136,7 +124,6 @@ mod tests {
|
||||
let handler = std::thread::spawn(move || {
|
||||
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(first.method, "test.large");
|
||||
assert!(first.deadline.is_some_and(|deadline| deadline > std::time::Instant::now()), "native requests carry their reply deadline");
|
||||
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
|
||||
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(second.method, "test.small");
|
||||
@@ -167,86 +154,4 @@ mod tests {
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_json_reply_carries_a_null_id_and_the_connection_keeps_serving() {
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve(stream, TOKEN, tx, egui::Context::default());
|
||||
});
|
||||
let handler = std::thread::spawn(move || {
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(req.method, "test.after");
|
||||
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
writeln!(stream, "{}", json!({"id": "auth", "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["ok"], true);
|
||||
writeln!(stream, "{{not json}}").unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let bad: Value = serde_json::from_str(&line).unwrap();
|
||||
let bad = bad.as_object().unwrap();
|
||||
assert_eq!(bad.get("id"), Some(&Value::Null), "bad JSON reply must carry `id: null`: {line}");
|
||||
assert_eq!(bad.get("ok"), Some(&Value::Bool(false)));
|
||||
assert!(bad.get("error").and_then(Value::as_str).unwrap().starts_with("bad JSON: "));
|
||||
writeln!(stream, "{}", json!({"id": 7, "method": "test.after"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let after: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(after["id"], 7);
|
||||
assert_eq!(after["result"], "still serving");
|
||||
drop(reader);
|
||||
drop(stream);
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_reports_already_authenticated_without_dispatching() {
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve(stream, TOKEN, tx, egui::Context::default());
|
||||
});
|
||||
let handler = std::thread::spawn(move || {
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
|
||||
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
for id in ["auth", "again"] {
|
||||
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
}
|
||||
let again: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(again["id"], "again");
|
||||
assert_eq!(again["ok"], false);
|
||||
assert_eq!(again["error"], "already authenticated");
|
||||
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let after: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(after["id"], 9);
|
||||
assert_eq!(after["result"], "still serving");
|
||||
drop(reader);
|
||||
drop(stream);
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
|
||||
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
|
||||
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
|
||||
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
|
||||
//!
|
||||
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
|
||||
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
|
||||
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
|
||||
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
//!
|
||||
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
|
||||
//! be read.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
|
||||
}
|
||||
|
||||
/// The pointer in points from the top-left of the content view of our frontmost window under
|
||||
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
|
||||
/// searched: a drop that reached us landed on one of them.
|
||||
fn in_window_points() -> Option<(f64, f64)> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let at = NSEvent::mouseLocation();
|
||||
let contains = |w: &NSWindow| {
|
||||
let f = w.frame();
|
||||
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
|
||||
};
|
||||
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
|
||||
let view = window.contentView()?;
|
||||
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
|
||||
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
|
||||
Some((p.x, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
let window = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
|
||||
RawWindowHandle::Xcb(h) => h.window.get(),
|
||||
_ => return None,
|
||||
};
|
||||
// Opened on first use and kept: the pointer is read every frame while files hover.
|
||||
let mut conn: Option<RustConnection> = None;
|
||||
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
|
||||
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read the pointer position: {e}");
|
||||
conn = None;
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
|
||||
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
|
||||
let conn = match conn {
|
||||
Some(c) => c,
|
||||
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
|
||||
};
|
||||
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
Some(Box::new(|ctx: &egui::Context| {
|
||||
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
|
||||
let inner = ctx.input(|i| i.viewport().inner_rect)?;
|
||||
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen position in physical pixels to points in the window whose client area starts at
|
||||
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
|
||||
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
|
||||
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
|
||||
/// monitors with different DPIs.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
|
||||
((screen_px - inner_min * ppp) / ppp).to_pos2()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CursorPosFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::pos2;
|
||||
|
||||
#[test]
|
||||
fn client_points_on_a_second_monitor_at_another_scale() {
|
||||
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
|
||||
let inner_min = pos2(-2400.0, 300.0) / 1.5;
|
||||
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
|
||||
// The same window on the 100% primary.
|
||||
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Why a File › Open / Save dialog may never appear on Linux (#3354).
|
||||
//!
|
||||
//! `rfd` talks to the XDG file-chooser portal through `libdbus-1`, which it loads at runtime, and
|
||||
//! falls back to `zenity`. When neither works (a minimal window-manager session without a portal
|
||||
//! backend) it logs the failure and answers "nothing picked", which the app took for Cancel: the
|
||||
//! menu item did nothing and said nothing. [`start`] looks for the pieces once, in the background,
|
||||
//! and [`known_problem`] returns what to install when a dialog came back empty and none of them
|
||||
//! is there.
|
||||
//!
|
||||
//! The probe only ever explains an empty answer; it never stops a dialog from opening. Anything
|
||||
//! it can't tell counts as present, so a working system never sees the message.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// What the probe found. `None` means it couldn't tell, which counts as present.
|
||||
// Only Linux probes; the types and helpers stay testable on every platform.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Findings {
|
||||
/// `libdbus-1.so.3` is in the linker cache (`rfd` loads it for the portal).
|
||||
pub libdbus: Option<bool>,
|
||||
/// The session bus has the portal's `FileChooser` interface (a portal and a backend for it).
|
||||
pub file_chooser_portal: Option<bool>,
|
||||
/// `zenity` is on `PATH` (`rfd`'s fallback).
|
||||
pub zenity: bool,
|
||||
}
|
||||
|
||||
/// The message for `findings`, or `None` when some file dialog should work.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn problem(findings: Findings) -> Option<String> {
|
||||
let portal = findings.libdbus != Some(false) && findings.file_chooser_portal != Some(false);
|
||||
if portal || findings.zenity {
|
||||
return None;
|
||||
}
|
||||
let cause = if findings.libdbus == Some(false) { "libdbus is missing" } else { "no desktop portal offers a file chooser" };
|
||||
Some(format!(
|
||||
"No file dialog can open: {cause}. Install xdg-desktop-portal with a backend (xdg-desktop-portal-gtk, -kde or -gnome) and libdbus, or zenity. Meanwhile, drop a file onto the window or paste a copied image."
|
||||
))
|
||||
}
|
||||
|
||||
static RESULT: OnceLock<Option<String>> = OnceLock::new();
|
||||
|
||||
/// The problem found by [`start`], once it has finished.
|
||||
pub fn known_problem() -> Option<String> {
|
||||
RESULT.get().cloned().flatten()
|
||||
}
|
||||
|
||||
/// Whether `program` is an executable file in one of the `path` directories.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn on_path(program: &str, path: &std::ffi::OsStr) -> bool {
|
||||
std::env::split_paths(path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
/// Look for the file dialog backends on a helper thread (the portal call can take a moment).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start() {
|
||||
let spawned = std::thread::Builder::new().name("file dialog probe".into()).spawn(|| {
|
||||
let findings = Findings {
|
||||
libdbus: crate::linux_libs::cached("libdbus-1.so.3"),
|
||||
file_chooser_portal: file_chooser_portal(),
|
||||
zenity: std::env::var_os("PATH").is_some_and(|path| on_path("zenity", &path)),
|
||||
};
|
||||
log::info!("file dialog backends: {findings:?}");
|
||||
let _ = RESULT.set(problem(findings));
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("couldn't start the file dialog probe: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn file_chooser_portal() -> Option<bool> {
|
||||
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::<zbus::blocking::Proxy<'_>>::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::<u32>("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,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn found(libdbus: Option<bool>, file_chooser_portal: Option<bool>, zenity: bool) -> Findings {
|
||||
Findings { libdbus, file_chooser_portal, zenity }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_backend_is_not_reported() {
|
||||
assert_eq!(problem(found(Some(true), Some(true), false)), None, "portal");
|
||||
assert_eq!(problem(found(Some(false), Some(false), true)), None, "zenity alone");
|
||||
assert_eq!(problem(found(None, None, false)), None, "unknown counts as present");
|
||||
assert_eq!(problem(found(Some(true), None, false)), None, "slow portal reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_show_a_dialog_with_is_explained() {
|
||||
let no_portal = problem(found(Some(true), Some(false), false)).unwrap();
|
||||
assert!(no_portal.contains("no desktop portal offers a file chooser") && no_portal.contains("xdg-desktop-portal"), "{no_portal}");
|
||||
let no_dbus = problem(found(Some(false), Some(true), false)).unwrap();
|
||||
assert!(no_dbus.contains("libdbus is missing"), "{no_dbus}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_are_found_on_the_given_path() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-probe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("zenity"), b"").unwrap();
|
||||
let path = std::env::join_paths([std::path::Path::new("/nonexistent"), dir.as_path()]).unwrap();
|
||||
assert!(on_path("zenity", &path));
|
||||
assert!(!on_path("kdialog", &path));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,907 +0,0 @@
|
||||
//! Crash-safe GPU startup (#4).
|
||||
//!
|
||||
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
|
||||
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
|
||||
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
|
||||
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
|
||||
//! rendered. If the next launch finds a marker that no running process holds, the previous start
|
||||
//! died inside the driver, and this one retries with the next safer backend:
|
||||
//!
|
||||
//! - Windows: Vulkan → DX12 → CPU
|
||||
//! - Linux and other Unix: Vulkan → GL → CPU
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
|
||||
//! (NVIDIA's EGL, which also hides llvmpipe) falls back to a Vulkan adapter for the window only
|
||||
//! (#1337); before, CPU mode then couldn't start at all.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
pub const MARKER_FILE: &str = "gpu-starting.json";
|
||||
/// PCI vendor id of Intel.
|
||||
pub const INTEL: u32 = 0x8086;
|
||||
/// Largest marker read (a marker is a few hundred bytes).
|
||||
const MARKER_MAX: u64 = 64 << 10;
|
||||
|
||||
/// Platform families with different backend chains.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Os {
|
||||
Windows,
|
||||
Mac,
|
||||
/// Linux, the BSDs and other Unix.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
pub fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Os::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Os::Mac
|
||||
} else {
|
||||
Os::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a start records before touching the driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Marker {
|
||||
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
|
||||
pub backend: String,
|
||||
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
|
||||
pub adapter: String,
|
||||
pub adapter_backend: String,
|
||||
pub driver: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn to_json(&self) -> String {
|
||||
json!({
|
||||
"backend": self.backend,
|
||||
"adapter": self.adapter,
|
||||
"adapterBackend": self.adapter_backend,
|
||||
"driver": self.driver,
|
||||
"version": self.version,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
|
||||
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
|
||||
}
|
||||
|
||||
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
|
||||
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
|
||||
pub fn tried(&self) -> Option<GpuBackend> {
|
||||
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
|
||||
match (planned, GpuBackend::parse(&self.adapter_backend)) {
|
||||
(GpuBackend::Auto, Some(actual)) => Some(actual),
|
||||
_ => Some(planned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend this launch uses and why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub backend: GpuBackend,
|
||||
/// `WGPU_BACKEND`, which picks the backends itself.
|
||||
pub env: Option<String>,
|
||||
/// Why this isn't simply the preference.
|
||||
pub reason: Option<String>,
|
||||
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
/// A backend the platform has (others fall back to `auto`).
|
||||
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, pref) {
|
||||
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next safer backend after a start on `tried` crashed.
|
||||
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, tried) {
|
||||
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
|
||||
(Os::Windows, Dx12 | Cpu) => Cpu,
|
||||
(Os::Mac, _) => Cpu,
|
||||
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
|
||||
(Os::Other, Gl | Cpu) => Cpu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a start that returned an error (so it didn't crash in the driver) keeps its marker, so
|
||||
/// the next start tries a safer backend. Not when there is none: the CPU path is the end of every
|
||||
/// chain, and a marker left there pinned every later start to the same failing path. Not under
|
||||
/// `WGPU_BACKEND` either (no plan reads that marker).
|
||||
pub fn keep_marker_after_error(plan: &Plan, os: Os) -> bool {
|
||||
plan.env.is_none() && next_safer(plan.backend, os) != plan.backend
|
||||
}
|
||||
|
||||
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
|
||||
/// `WGPU_BACKEND` and `--safe-gpu`.
|
||||
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
|
||||
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
|
||||
}
|
||||
if safe_gpu {
|
||||
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
|
||||
}
|
||||
let pref = sanitize(pref, os);
|
||||
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
|
||||
let next = next_safer(tried, os);
|
||||
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
|
||||
return Plan {
|
||||
backend: next,
|
||||
env: None,
|
||||
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
|
||||
remember: next != pref,
|
||||
};
|
||||
}
|
||||
Plan { backend: pref, env: None, reason: None, remember: false }
|
||||
}
|
||||
|
||||
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
|
||||
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
|
||||
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
let backend = match mode {
|
||||
RenderingMode::Cpu => GpuBackend::Cpu,
|
||||
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
|
||||
_ => pref,
|
||||
};
|
||||
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
|
||||
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
|
||||
plan(backend, crashed, env, safe_gpu, os)
|
||||
}
|
||||
|
||||
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
|
||||
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
|
||||
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
|
||||
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(match plan.backend {
|
||||
GpuBackend::Auto => return None,
|
||||
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
|
||||
GpuBackend::Dx12 => wgpu::Backends::DX12,
|
||||
GpuBackend::Metal => wgpu::Backends::METAL,
|
||||
GpuBackend::Gl => wgpu::Backends::GL,
|
||||
GpuBackend::Cpu => match os {
|
||||
Os::Windows => wgpu::Backends::DX12,
|
||||
Os::Mac => wgpu::Backends::METAL,
|
||||
// Vulkan only for the window when no GL adapter can present (`rank`): with NVIDIA's
|
||||
// EGL the GL adapter can't, and CPU mode failed every start (#1337).
|
||||
Os::Other => wgpu::Backends::GL | wgpu::Backends::VULKAN,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
|
||||
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
|
||||
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
|
||||
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
|
||||
return c;
|
||||
}
|
||||
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
|
||||
match beside_exe.as_deref().and_then(Path::to_str) {
|
||||
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
|
||||
None => wgpu::Dx12Compiler::Fxc,
|
||||
}
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub vendor: u32,
|
||||
pub device_type: wgpu::DeviceType,
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
|
||||
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
|
||||
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
wgpu::DeviceType::VirtualGpu => 2,
|
||||
wgpu::DeviceType::Other => 3,
|
||||
wgpu::DeviceType::Cpu => 4,
|
||||
};
|
||||
let ty = match (backend, a.device_type) {
|
||||
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
|
||||
(GpuBackend::Cpu, _) => ty + 1,
|
||||
_ => ty,
|
||||
};
|
||||
let be = match a.backend {
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
|
||||
};
|
||||
(fallback, ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
/// crashes on them on old Intel Macs (Iris Pro, GPUFamily 1 on Monterey), so no mode could start
|
||||
/// (#3107). Only Intel Macs skip them: PhotoCraft issues no indirect draws or dispatches, but every
|
||||
/// other GPU keeps wgpu's default. `WGPU_VALIDATION_INDIRECT_CALL=1` still wins.
|
||||
fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
os == Os::Mac && intel
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
if skips_indirect_validation(os, cfg!(target_arch = "x86_64")) {
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
if os == Os::Windows {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
let cands: Vec<Candidate> = adapters
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
Candidate {
|
||||
vendor: i.vendor,
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
|
||||
let info = adapter.get_info();
|
||||
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(s) = guard.as_mut() {
|
||||
let mut m = s.marker.clone();
|
||||
m.adapter = info.name.clone();
|
||||
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
|
||||
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
if let Err(e) = s.write(m) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a previous start left behind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Previous {
|
||||
/// Nothing: it finished starting (or never ran).
|
||||
Clean,
|
||||
/// Its marker, unlocked: it died before rendering its first frames.
|
||||
Crashed(Marker),
|
||||
/// Another instance is starting right now (it holds the lock).
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl Previous {
|
||||
pub fn crashed(&self) -> Option<&Marker> {
|
||||
match self {
|
||||
Previous::Crashed(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The locked marker file of this start.
|
||||
#[derive(Debug)]
|
||||
pub struct Sentinel {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
marker: Marker,
|
||||
}
|
||||
|
||||
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
|
||||
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
|
||||
|
||||
/// Only renderer errors should affect the next launch's graphics backend. Windowing errors
|
||||
/// (such as a missing Linux display) and app setup errors never indicate a driver failure.
|
||||
pub fn keep_marker_after_run(result: &eframe::Result) -> bool {
|
||||
matches!(result, Err(eframe::Error::Wgpu(_)))
|
||||
}
|
||||
|
||||
impl Sentinel {
|
||||
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
|
||||
/// unless another instance holds the lock or the directory isn't writable, this start's
|
||||
/// sentinel (not yet written: see [`Sentinel::write`]).
|
||||
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let path = dir.join(MARKER_FILE);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("GPU startup marker {}: {e}", path.display());
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
log::warn!("GPU startup marker lock: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
|
||||
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
|
||||
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
|
||||
}
|
||||
|
||||
/// Replace the marker's contents with `m`.
|
||||
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
|
||||
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
|
||||
f.set_len(0)?;
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.write_all(m.to_json().as_bytes())?;
|
||||
f.flush()?;
|
||||
self.marker = m;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The start succeeded: clear the marker and release the lock.
|
||||
pub fn finish(mut self) {
|
||||
if let Some(f) = self.file.take() {
|
||||
// Empty means "no crash" even if removing fails (another instance has it open).
|
||||
let _ = f.set_len(0);
|
||||
drop(f);
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Linux display-server preference (`performance.linuxDisplayServer`) from the preferences
|
||||
/// file, read before the window opens; `Auto` when the file or value is missing or unknown.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
v.get("performance")
|
||||
.and_then(|p| serde_json::from_value::<photocraft_engine::prefs::Performance>(p.clone()).ok())
|
||||
.map(|performance| performance.linux_display_server)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let perf = v.get("performance");
|
||||
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
|
||||
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
|
||||
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
|
||||
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
|
||||
(backend, mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GpuBackend::*;
|
||||
|
||||
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-display-server-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_display_server(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11"}}"#), LinuxDisplayServer::X11);
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"auto","gpuBackend":"vulkan"}}"#), LinuxDisplayServer::Auto);
|
||||
// Unknown values, other types, broken JSON and a missing file all start as Auto.
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read("{not json"), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto);
|
||||
assert_eq!(read_display_server(None), LinuxDisplayServer::Auto);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_policy_ignores_hardware_backend_override() {
|
||||
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
|
||||
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
|
||||
assert_eq!(p.backend, Cpu);
|
||||
assert!(p.env.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_preference() {
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
let p = plan(Auto, None, None, false, os);
|
||||
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
|
||||
}
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
|
||||
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
|
||||
// A backend the platform lacks is `auto`.
|
||||
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_moves_down_each_chain_and_is_remembered() {
|
||||
// Windows: Vulkan → DX12 → CPU.
|
||||
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Dx12, true));
|
||||
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
|
||||
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Crashed before the adapter was chosen (enumeration): from `auto`.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
|
||||
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Linux: Vulkan → GL → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
|
||||
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
|
||||
// macOS: Metal → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
|
||||
// The CPU path stays there, and isn't "remembered" again.
|
||||
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
// An unreadable marker still counts as a crash.
|
||||
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_everything() {
|
||||
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.env.as_deref(), Some("vulkan"));
|
||||
assert_eq!(p.backend, Auto);
|
||||
assert!(!p.remember);
|
||||
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
|
||||
// A crash under the override doesn't count against `auto` later.
|
||||
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
|
||||
assert_eq!(p.backend, Auto);
|
||||
// Empty means unset.
|
||||
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
|
||||
// --safe-gpu with the override: CPU canvas, the override's backends.
|
||||
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
|
||||
}
|
||||
|
||||
/// #3483: A temporary CPU recovery must not change the next launch.
|
||||
#[test]
|
||||
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
|
||||
let gl_crash = crashed("gl", "gl");
|
||||
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
|
||||
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
|
||||
assert_eq!(backend_to_remember(&cpu), None);
|
||||
|
||||
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
|
||||
assert_eq!(safe.backend, Cpu);
|
||||
assert_eq!(backend_to_remember(&safe), None);
|
||||
// An unconfirmed fallback leaves the original automatic selection intact.
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
|
||||
// A working hardware fallback can still be remembered.
|
||||
let vk_crash = crashed("auto", "vulkan");
|
||||
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
|
||||
assert_eq!(backend_to_remember(&gl), Some(Gl));
|
||||
// CPU explicitly selected in preferences still works.
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_gpu_forces_the_cpu_for_one_launch() {
|
||||
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
|
||||
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL | wgpu::Backends::VULKAN));
|
||||
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_on_windows_defaults_to_dx12() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
// Adapters that can't present lose to ones that can.
|
||||
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
|
||||
only[0].surface_ok = false;
|
||||
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
|
||||
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
|
||||
// No software adapter: still something.
|
||||
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
|
||||
}
|
||||
|
||||
/// #1337: on Linux with NVIDIA, the only GL adapter (NVIDIA's EGL) can't present, so CPU mode
|
||||
/// found no adapter and never started. GL still wins whenever it can present.
|
||||
#[test]
|
||||
fn cpu_on_linux_falls_back_to_vulkan_only_when_gl_cannot_present() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
const NVIDIA: u32 = 0x10de;
|
||||
let llvmpipe = cand(0x10005, T::Cpu, B::Gl);
|
||||
let nvidia_gl = cand(NVIDIA, T::DiscreteGpu, B::Gl);
|
||||
let nvidia_vk = cand(NVIDIA, T::DiscreteGpu, B::Vulkan);
|
||||
let lavapipe = cand(0x10005, T::Cpu, B::Vulkan);
|
||||
// GL presents: same choice as GL-only, software first, then hardware GL over any Vulkan.
|
||||
assert_eq!(pick(&[nvidia_vk, lavapipe, llvmpipe], Cpu, Os::Other), Some(2));
|
||||
assert_eq!(pick(&[nvidia_vk, lavapipe, nvidia_gl], Cpu, Os::Other), Some(2));
|
||||
// GL can't present: Vulkan for the window, software first.
|
||||
let mut gl = nvidia_gl;
|
||||
gl.surface_ok = false;
|
||||
assert_eq!(pick(&[gl, nvidia_vk, lavapipe], Cpu, Os::Other), Some(2));
|
||||
assert_eq!(pick(&[gl, nvidia_vk], Cpu, Os::Other), Some(1));
|
||||
// Other backends rank as before.
|
||||
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
|
||||
/// GL drew a black window; WARP works. GL stays the last resort.
|
||||
#[test]
|
||||
fn windows_auto_prefers_warp_over_gl() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let svga_gl = cand(0x15ad, T::Other, B::Gl);
|
||||
let warp = cand(0x1414, T::Cpu, B::Dx12);
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
|
||||
// Elsewhere GL keeps its rank.
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_must_present_even_when_none_support_the_surface() {
|
||||
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
|
||||
a.surface_ok = false;
|
||||
assert_eq!(pick(&[a], Auto, Os::Windows), None);
|
||||
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_policy_overrides_legacy_cpu_backend() {
|
||||
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trip_and_lenient_parse() {
|
||||
let m = Marker {
|
||||
backend: "auto".into(),
|
||||
adapter: "A \"quoted\" GPU".into(),
|
||||
adapter_backend: "vulkan".into(),
|
||||
driver: "1.2".into(),
|
||||
version: "0.1".into(),
|
||||
};
|
||||
assert_eq!(Marker::parse(&m.to_json()), m);
|
||||
assert_eq!(Marker::parse(""), Marker::default());
|
||||
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
|
||||
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
|
||||
assert_eq!(Marker::parse(&long).adapter.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_start_keeps_its_marker_only_when_a_safer_backend_exists() {
|
||||
// GPU backends move down the chain on the next start.
|
||||
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Other), Os::Other));
|
||||
assert!(keep_marker_after_error(&plan(Gl, None, None, false, Os::Other), Os::Other));
|
||||
assert!(keep_marker_after_error(&plan(Dx12, None, None, false, Os::Windows), Os::Windows));
|
||||
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Mac), Os::Mac));
|
||||
// The CPU path is the end of every chain, including the `--safe-gpu` retry.
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
assert!(!keep_marker_after_error(&plan(Cpu, None, None, false, os), os));
|
||||
assert!(!keep_marker_after_error(&plan(Auto, None, None, true, os), os));
|
||||
}
|
||||
// `WGPU_BACKEND` is the user's choice; no plan reads its marker.
|
||||
assert!(!keep_marker_after_error(&plan(Auto, None, Some("vulkan"), false, Os::Other), Os::Other));
|
||||
}
|
||||
|
||||
/// A failed CPU start (no usable GL adapter under XWayland) left a `cpu` marker, so every
|
||||
/// later start was planned on CPU and failed the same way until the marker was deleted by hand.
|
||||
#[test]
|
||||
fn a_failed_cpu_start_does_not_pin_later_starts() {
|
||||
let dir = temp_dir("cpu-error");
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
let failed = plan(Auto, None, None, true, Os::Other);
|
||||
s.write(Marker { backend: failed.backend.name().into(), ..Default::default() }).unwrap();
|
||||
// eframe returned an error: what `main` does with the marker.
|
||||
if keep_marker_after_error(&failed, Os::Other) {
|
||||
drop(s);
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (prev, _s) = begin_when_unlocked(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
|
||||
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
|
||||
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
|
||||
/// drops its sentinel.
|
||||
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let (mut prev, mut s) = Sentinel::begin(dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(dir);
|
||||
}
|
||||
(prev, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
// First launch: nothing left behind.
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
|
||||
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// While it is starting, a second instance sees it busy, not crashed.
|
||||
let (busy, none) = Sentinel::begin(&dir);
|
||||
assert_eq!(busy, Previous::Busy);
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
|
||||
s.finish();
|
||||
assert!(!dir.join(MARKER_FILE).exists());
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
|
||||
let dir = temp_dir("shared-lock");
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
|
||||
// spawned child's copy of the descriptor does until the child calls exec.
|
||||
let held = File::open(dir.join(MARKER_FILE)).unwrap();
|
||||
drop(s);
|
||||
held.lock().unwrap();
|
||||
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
held.unlock().unwrap();
|
||||
});
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
release.join().unwrap();
|
||||
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
|
||||
s.expect("sentinel").finish();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
|
||||
let cases = [
|
||||
("success", Ok(()), false),
|
||||
("app-error", Err(eframe::Error::AppCreation(Box::new(std::io::Error::other("app setup failed")))), false),
|
||||
("gpu-error", Err(eframe::Error::Wgpu(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no adapter".into()))), true),
|
||||
];
|
||||
for (name, result, keep) in cases {
|
||||
let dir = temp_dir(name);
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
if keep_marker_after_run(&result) {
|
||||
drop(s);
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (previous, s) = begin_when_unlocked(&dir);
|
||||
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
|
||||
if !keep {
|
||||
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
|
||||
}
|
||||
s.expect("sentinel").finish();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_config_dir_is_not_fatal() {
|
||||
let dir = temp_dir("file");
|
||||
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
|
||||
std::fs::write(&dir, b"not a directory").unwrap();
|
||||
let (prev, s) = Sentinel::begin(&dir.join("sub"));
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert!(s.is_none());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_are_read_leniently() {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
|
||||
#[test]
|
||||
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
|
||||
use wgpu::Dx12Compiler as C;
|
||||
let dir = temp_dir("dxc");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(matches!(dx12_compiler(None, None), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
|
||||
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
|
||||
let expected = dir.join("dxcompiler.dll");
|
||||
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
|
||||
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
|
||||
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
|
||||
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
|
||||
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
C::Fxc => true,
|
||||
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
|
||||
C::StaticDxc | C::Auto => false,
|
||||
};
|
||||
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// Metal's compiler crashed on them on Intel Iris Pro, losing the device in every mode (#3107).
|
||||
assert!(skips_indirect_validation(Os::Mac, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (os, intel) in [(Os::Mac, false), (Os::Windows, true), (Os::Other, true), (Os::Other, false)] {
|
||||
assert!(!skips_indirect_validation(os, intel), "{os:?} intel={intel}");
|
||||
}
|
||||
for (os, safe_gpu) in [(Os::Windows, false), (Os::Other, true), (Os::Other, false)] {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
|
||||
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
|
||||
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
|
||||
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
|
||||
/// theme lists, and the bytes read from one file.
|
||||
const MAX_THEMES: usize = 32;
|
||||
const MAX_DIRS: usize = 4096;
|
||||
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
|
||||
const SCAN_DEPTH: usize = 3;
|
||||
const MAX_FILE: u64 = 1 << 20;
|
||||
/// The size the caption glyphs are drawn at, in the spec's units.
|
||||
const ICON_SIZE: i64 = 16;
|
||||
|
||||
/// A theme or icon name that stays inside its directory.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
|
||||
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
|
||||
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
|
||||
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let absolute = |p: &String| Path::new(p).is_absolute();
|
||||
let home = var("HOME").filter(absolute).map(PathBuf::from);
|
||||
let mut dirs = vec![];
|
||||
if let Some(home) = &home {
|
||||
dirs.push(home.join(".icons"));
|
||||
}
|
||||
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
|
||||
dirs.push(data.join("icons"));
|
||||
}
|
||||
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// One `[section]` of an `index.theme`, as (key, value) pairs.
|
||||
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
|
||||
let mut inside = false;
|
||||
let mut pairs = vec![];
|
||||
for line in index.lines().map(str::trim) {
|
||||
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
if inside {
|
||||
break;
|
||||
}
|
||||
inside = s == name;
|
||||
} else if inside && let Some((k, v)) = line.split_once('=') {
|
||||
pairs.push((k.trim(), v.trim()));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
|
||||
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
|
||||
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
|
||||
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
|
||||
let size = num("Size", 0);
|
||||
let scale = num("Scale", 1).max(1);
|
||||
let d = match get("Type").unwrap_or("Threshold") {
|
||||
"Fixed" => (size * scale - ICON_SIZE).abs(),
|
||||
"Scalable" => {
|
||||
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
|
||||
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
|
||||
}
|
||||
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
|
||||
};
|
||||
(d, scale)
|
||||
}
|
||||
|
||||
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
|
||||
/// path order: scalable and symbolic ones first, as icons there fit any size.
|
||||
fn subdirectories(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..SCAN_DEPTH {
|
||||
let mut next = vec![];
|
||||
for dir in &level {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten().take(MAX_DIRS) {
|
||||
let path = entry.path();
|
||||
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
|
||||
next.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.extend(next.iter().cloned());
|
||||
level = next;
|
||||
}
|
||||
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
|
||||
found.sort_by_key(|p| (vector(p), p.clone()));
|
||||
found
|
||||
}
|
||||
|
||||
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
|
||||
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
|
||||
struct Theme {
|
||||
roots: Vec<PathBuf>,
|
||||
listed: Vec<PathBuf>,
|
||||
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
|
||||
/// often add `symbolic/ui` without listing it.
|
||||
fn unlisted(&self) -> &[PathBuf] {
|
||||
self.unlisted.get_or_init(|| {
|
||||
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
|
||||
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme and the themes it inherits, in search order.
|
||||
pub struct IconTheme {
|
||||
themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
impl IconTheme {
|
||||
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
|
||||
/// `index.theme`.
|
||||
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
|
||||
let mut chain: Vec<String> = vec![name.to_string()];
|
||||
let mut themes = vec![];
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(theme) = chain.get(i).cloned() else {
|
||||
// Every chain ends in hicolor, whether or not the themes before it exist.
|
||||
if chain.iter().any(|t| t == "hicolor") {
|
||||
break;
|
||||
}
|
||||
chain.push("hicolor".into());
|
||||
continue;
|
||||
};
|
||||
i += 1;
|
||||
if !valid_name(&theme) {
|
||||
continue;
|
||||
}
|
||||
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
|
||||
// The first index wins; a theme can still be spread over several base directories.
|
||||
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
|
||||
let index = String::from_utf8_lossy(&index);
|
||||
let head = section(&index, "Icon Theme");
|
||||
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
|
||||
.chain(list(get("ScaledDirectories")))
|
||||
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
|
||||
.take(MAX_DIRS)
|
||||
.map(|d| (distance(§ion(&index, d)), d))
|
||||
.collect();
|
||||
subdirs.sort_by_key(|(key, _)| *key);
|
||||
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
|
||||
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
|
||||
for parent in list(get("Inherits")) {
|
||||
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
|
||||
chain.push(parent.to_string());
|
||||
}
|
||||
}
|
||||
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
|
||||
}
|
||||
(!themes.is_empty()).then_some(Self { themes })
|
||||
}
|
||||
|
||||
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
|
||||
/// ones, before its parent.
|
||||
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
|
||||
if !valid_name(icon) {
|
||||
return None;
|
||||
}
|
||||
let file = format!("{icon}.svg");
|
||||
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
|
||||
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
|
||||
/// unreadable SVG or an absurd size.
|
||||
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
if !(1..=256).contains(&px) {
|
||||
return None;
|
||||
}
|
||||
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
|
||||
// (`/dev/zero`, a huge picture) or nest an SVG.
|
||||
let opts = usvg::Options {
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
|
||||
..usvg::Options::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
let side = size.width().max(size.height());
|
||||
if !side.is_finite() || side <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let s = px as f32 / side;
|
||||
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
|
||||
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throwaway icon tree: `files` are (path under the base, contents).
|
||||
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for (path, text) in files {
|
||||
let p = base.join(path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
|
||||
|
||||
#[test]
|
||||
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
|
||||
let base = tree(
|
||||
"chain",
|
||||
&[
|
||||
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
|
||||
("Child/48/a-symbolic.svg", "big"),
|
||||
// Not in the index: still found, and before the parent's copy.
|
||||
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
|
||||
("Parent/ui/d-symbolic.svg", SQUARE),
|
||||
("Child/16/a-symbolic.svg", SQUARE),
|
||||
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
|
||||
("Parent/ui/b-symbolic.svg", SQUARE),
|
||||
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
|
||||
("hicolor/s/c-symbolic.svg", SQUARE),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
|
||||
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
|
||||
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
|
||||
assert_eq!(theme.find_svg("missing"), None);
|
||||
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
|
||||
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
|
||||
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
|
||||
#[test]
|
||||
fn cyclic_or_hostile_indexes_are_harmless() {
|
||||
let base = tree(
|
||||
"hostile",
|
||||
&[
|
||||
(
|
||||
"A/index.theme",
|
||||
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
|
||||
),
|
||||
("A/ok/x.svg", SQUARE),
|
||||
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
|
||||
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
|
||||
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_follows_the_spec() {
|
||||
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
|
||||
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
|
||||
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
|
||||
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_square_coverage() {
|
||||
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
|
||||
assert_eq!(mask.len(), 32 * 32);
|
||||
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
|
||||
assert_eq!(mask[0], 0, "outside it");
|
||||
assert!(rasterize_mask(b"not svg", 16).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
|
||||
}
|
||||
|
||||
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
|
||||
#[test]
|
||||
fn masks_load_no_images() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
|
||||
<image href="/dev/zero" width="16" height="16"/>
|
||||
<image xlink:href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>" width="16" height="16"/>
|
||||
</svg>"##;
|
||||
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
|
||||
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dirs_follow_xdg() {
|
||||
let var = |k: &str| match k {
|
||||
"HOME" => Some("/home/u".to_string()),
|
||||
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let dirs = base_dirs(&var);
|
||||
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
|
||||
assert_eq!(dirs, want);
|
||||
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
|
||||
//!
|
||||
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
|
||||
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
|
||||
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
|
||||
//! before eframe starts, we look for the libraries the current session type needs and, if one is
|
||||
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
|
||||
//! crash).
|
||||
//!
|
||||
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
|
||||
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
|
||||
//! it can't be read (musl, NixOS, some containers) and a library isn't found in the directories,
|
||||
//! that proves nothing, so we start silently. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
|
||||
//!
|
||||
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
|
||||
//! and [`available`] touch the system, and they are only called on Linux.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
|
||||
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplaySession {
|
||||
Wayland,
|
||||
X11,
|
||||
/// Neither is set: winit reports that itself (an error, not a panic).
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
DisplaySession::Wayland
|
||||
} else if set("DISPLAY") {
|
||||
DisplaySession::X11
|
||||
} else {
|
||||
DisplaySession::None
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime-loaded library and the package that provides it on each distro family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Lib {
|
||||
pub soname: &'static str,
|
||||
pub debian: &'static str,
|
||||
pub fedora: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub suse: &'static str,
|
||||
}
|
||||
|
||||
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
|
||||
Lib { soname, debian, fedora, arch, suse }
|
||||
}
|
||||
|
||||
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
|
||||
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
|
||||
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
|
||||
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
|
||||
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
|
||||
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
|
||||
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
|
||||
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
|
||||
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
|
||||
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
|
||||
|
||||
/// One requirement: at least one of these libraries must be present.
|
||||
pub type AnyOf = &'static [Lib];
|
||||
|
||||
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
|
||||
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
|
||||
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
|
||||
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
|
||||
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
|
||||
const GPU: AnyOf = &[VULKAN, EGL];
|
||||
match session {
|
||||
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
|
||||
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
|
||||
DisplaySession::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
let Some((name, rest)) = line.split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache" (translated on a system whose
|
||||
/// locale `LC_ALL=C` doesn't reach): a count, then the cache's name.
|
||||
fn ldconfig_output_is_valid(output: &str) -> bool {
|
||||
output.lines().next().is_some_and(|l| l.split_whitespace().next().is_some_and(|n| n.parse::<u32>().is_ok()))
|
||||
}
|
||||
|
||||
/// Where we look for libraries, and what we found there.
|
||||
pub struct Probe {
|
||||
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
|
||||
pub ldconfig: Option<String>,
|
||||
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
|
||||
pub dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
|
||||
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the check.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Ok,
|
||||
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
|
||||
/// cache was read, so the libraries really are absent (and winit would fail or panic).
|
||||
Missing {
|
||||
missing: Vec<AnyOf>,
|
||||
conclusive: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
|
||||
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
|
||||
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
|
||||
}
|
||||
|
||||
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Distro {
|
||||
Debian,
|
||||
Fedora,
|
||||
Arch,
|
||||
Suse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn distro_from_os_release(text: &str) -> Distro {
|
||||
let mut ids = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
|
||||
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
|
||||
}
|
||||
}
|
||||
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
|
||||
if any(&["debian", "ubuntu"]) {
|
||||
Distro::Debian
|
||||
} else if any(&["fedora", "rhel", "centos"]) {
|
||||
Distro::Fedora
|
||||
} else if any(&["arch"]) {
|
||||
Distro::Arch
|
||||
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
|
||||
Distro::Suse
|
||||
} else {
|
||||
Distro::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn package(lib: &Lib, distro: Distro) -> &'static str {
|
||||
match distro {
|
||||
Distro::Debian | Distro::Unknown => lib.debian,
|
||||
Distro::Fedora => lib.fedora,
|
||||
Distro::Arch => lib.arch,
|
||||
Distro::Suse => lib.suse,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_command(packages: &[&str], distro: Distro) -> String {
|
||||
let list = packages.join(" ");
|
||||
match distro {
|
||||
Distro::Debian => format!("sudo apt install {list}"),
|
||||
Distro::Fedora => format!("sudo dnf install {list}"),
|
||||
Distro::Arch => format!("sudo pacman -S {list}"),
|
||||
Distro::Suse => format!("sudo zypper install {list}"),
|
||||
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for missing libraries: what's missing, and the command that installs it.
|
||||
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro) -> String {
|
||||
let session_name = match session {
|
||||
DisplaySession::Wayland => "Wayland",
|
||||
DisplaySession::X11 => "X11",
|
||||
DisplaySession::None => "display",
|
||||
};
|
||||
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
|
||||
// For an either/or requirement, suggest its first alternative.
|
||||
let mut packages: Vec<&str> = Vec::new();
|
||||
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
|
||||
if !packages.contains(&p) {
|
||||
packages.push(p);
|
||||
}
|
||||
}
|
||||
let mut msg = format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "));
|
||||
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
|
||||
if distro == Distro::Unknown {
|
||||
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
|
||||
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
|
||||
}
|
||||
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
|
||||
msg
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") {
|
||||
"aarch64-linux-gnu"
|
||||
} else if cfg!(target_arch = "riscv64") {
|
||||
"riscv64-linux-gnu"
|
||||
} else {
|
||||
"x86_64-linux-gnu"
|
||||
};
|
||||
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
|
||||
// The listing is parsed, so it must be the untranslated one: a German system prints
|
||||
// "5522 Bibliotheken im Cache", which `ldconfig_output_is_valid` took for no cache at all.
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").env("LC_ALL", "C").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if ldconfig_output_is_valid(&text) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// This system's linker cache and library directories.
|
||||
fn system_probe() -> Probe {
|
||||
Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) }
|
||||
}
|
||||
|
||||
/// Whether the linker cache lists `soname`; `None` when the cache can't be read, which proves
|
||||
/// nothing (musl, NixOS, some containers).
|
||||
pub fn cached(soname: &str) -> Option<bool> {
|
||||
read_ldconfig().map(|text| ldconfig_has(&text, soname, ldconfig_arch_tag()))
|
||||
}
|
||||
|
||||
/// Are all the libraries `session` needs found on this system? (For choosing a display server:
|
||||
/// libraries the check can't find count as missing.)
|
||||
pub fn available(session: DisplaySession) -> bool {
|
||||
check(session, &system_probe(), |p| p.exists()) == Verdict::Ok
|
||||
}
|
||||
|
||||
/// Run the check for the display server the window opens on against this system. `Err` carries
|
||||
/// the message to print before exiting with a non-zero status.
|
||||
pub fn preflight(session: DisplaySession) -> Result<(), String> {
|
||||
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
|
||||
return Ok(());
|
||||
}
|
||||
if session == DisplaySession::None {
|
||||
return Ok(());
|
||||
}
|
||||
let verdict = check(session, &system_probe(), |p| p.exists());
|
||||
decide(session, verdict, || std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown))
|
||||
}
|
||||
|
||||
/// `Err` with the message to print only when the linker cache was read and the libraries are
|
||||
/// really absent. Without the cache (musl has no `ldconfig -p`; NixOS, AppImages that ship their
|
||||
/// own libraries) not finding a file proves nothing, so we start without a misleading warning.
|
||||
fn decide(session: DisplaySession, verdict: Verdict, distro: impl FnOnce() -> Distro) -> Result<(), String> {
|
||||
match verdict {
|
||||
Verdict::Missing { missing, conclusive: true } => Err(missing_message(session, &missing, distro())),
|
||||
Verdict::Missing { conclusive: false, .. } | Verdict::Ok => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_follows_winit_order() {
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
|
||||
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
|
||||
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
|
||||
}
|
||||
|
||||
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
|
||||
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
|
||||
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
|
||||
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
|
||||
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
|
||||
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
assert!(ldconfig_output_is_valid(CACHE));
|
||||
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
|
||||
// A translated listing is still a listing (it used to be rejected, so the check was
|
||||
// silently skipped on non-English systems).
|
||||
assert!(ldconfig_output_is_valid("5522 Bibliotheken im Cache »/etc/ld.so.cache« gefunden\n\tlibz.so.1 (libc6,x86-64) => /usr/lib/libz.so.1\n"));
|
||||
assert!(!ldconfig_output_is_valid("Error: kein Cache\n"));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
|
||||
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
|
||||
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
|
||||
// Prefix of another name, or a missing name, is not a match.
|
||||
assert!(!ldconfig_has(CACHE, "libX11.so", None));
|
||||
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
|
||||
assert!(!ldconfig_has("", "libX11.so.6", None));
|
||||
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
|
||||
}
|
||||
|
||||
fn cache_for_this_arch(names: &[&str]) -> String {
|
||||
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
|
||||
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
|
||||
for n in names {
|
||||
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
const ALL_X11: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
|
||||
|
||||
#[test]
|
||||
fn x11_without_xkbcommon_x11_is_reported() {
|
||||
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
|
||||
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
|
||||
let v = check(DisplaySession::X11, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
|
||||
assert!(conclusive);
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
|
||||
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian);
|
||||
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
|
||||
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
|
||||
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_systems_pass() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
|
||||
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
|
||||
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
|
||||
// Wayland doesn't need the X11 libraries; no session needs nothing.
|
||||
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_needs_vulkan_or_egl() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
|
||||
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
|
||||
assert_eq!(missing.len(), 1);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora);
|
||||
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
|
||||
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_dirs_count_and_unread_cache_is_inconclusive() {
|
||||
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
|
||||
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
|
||||
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
|
||||
// Found by file in a directory (no ldconfig, as on NixOS).
|
||||
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
|
||||
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
|
||||
// Not found and no cache to confirm it: inconclusive (the app starts, see #1144).
|
||||
let v = check(DisplaySession::Wayland, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
|
||||
assert!(!conclusive);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown);
|
||||
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
|
||||
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_cache_never_warns_or_fails() {
|
||||
// Issue #1144: Alpine (musl) has no `ldconfig -p`, so a library that exists was reported missing.
|
||||
let probe = Probe { ldconfig: None, dirs: vec![] };
|
||||
let verdict = check(DisplaySession::X11, &probe, |_| false);
|
||||
assert_eq!(decide(DisplaySession::X11, verdict, || Distro::Debian), Ok(()));
|
||||
// A readable cache that lacks a library is still an error.
|
||||
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
|
||||
let verdict = check(DisplaySession::X11, &probe, |_| false);
|
||||
let Err(msg) = decide(DisplaySession::X11, verdict, || Distro::Debian) else { panic!("expected an error") };
|
||||
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_detection() {
|
||||
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
|
||||
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
|
||||
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release(""), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU startup fallbacks, a
|
||||
//! 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: `<settings dir>/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.
|
||||
//!
|
||||
//! 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`,
|
||||
//! `warn,photocraft_gpu=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`photocraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! 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::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "photocraft.log";
|
||||
/// How many previous logs are kept (`photocraft.1.log` … `photocraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,photocraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
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` → … → `.<KEEP>.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)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let thread = std::thread::current();
|
||||
let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("photocraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("photocraft::gpu_startup"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,photocraft_gpu=trace,photocraft_gpu::health=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("photocraft_gpu::canvas"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
for spec in ["", ",,,", "=", "=debug", "photocraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,photocraft=DEBUG"] {
|
||||
let f = Filter::parse(spec);
|
||||
let _ = f.level_for("photocraft");
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,photocraft=DEBUG").level_for("photocraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
// 2026-10-08T07:59:17.728Z
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("photocraft.1.log")).expect("1"), "run 3");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("photocraft.2.log")).expect("2"), "run 2");
|
||||
assert!(!dir.join("photocraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "photocraft::gpu_startup", "GPU startup marker: denied"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] photocraft::gpu_startup: GPU startup marker: denied\n"
|
||||
);
|
||||
// A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let record = |level, target: &'static str, msg: &'static str| {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
};
|
||||
record(log::Level::Info, "photocraft::app_dirs", "settings directory");
|
||||
record(log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(log::Level::Debug, "photocraft", "below info");
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" INFO [") && text.contains("photocraft::app_dirs: settings directory\n"), "{text}");
|
||||
assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}");
|
||||
assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("photocraft_gpu").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
use log::Log;
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
//! The macOS menu bar, through muda (a standalone native menu crate, not Tauri: no webview, just
|
||||
//! AppKit menus behind a safe API, no GTK on macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
|
||||
//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
|
||||
//!
|
||||
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
|
||||
//! gets `id#2` for its second copy, as muda ids must be unique.
|
||||
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
|
||||
//! else (labels, enabled, checked) is updated in place. Opening or closing a document rebuilds
|
||||
//! only the Window menu, whose list of documents changed: AppKit adds its own items to that
|
||||
//! menu (window tiling, the window list), so muda can't insert rows there by position.
|
||||
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
|
||||
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
|
||||
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
|
||||
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
|
||||
use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard};
|
||||
|
||||
enum Handle {
|
||||
Plain(MenuItem),
|
||||
Check(CheckMenuItem),
|
||||
}
|
||||
|
||||
/// One native item.
|
||||
struct Entry {
|
||||
handle: Handle,
|
||||
/// The command id (several entries can share one).
|
||||
command: String,
|
||||
chord: Option<Chord>,
|
||||
label: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// How an item was chosen, read from AppKit's current event while it calls the handler.
|
||||
struct Chosen {
|
||||
id: String,
|
||||
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
|
||||
key: bool,
|
||||
mods: [bool; 4],
|
||||
}
|
||||
|
||||
pub struct MacMenu {
|
||||
menu: Option<Menu>,
|
||||
/// By muda id.
|
||||
entries: HashMap<String, Entry>,
|
||||
rx: Receiver<Chosen>,
|
||||
structure: u64,
|
||||
/// The Window menu, the muda ids of its items, and its [`native_menu::window_key`].
|
||||
window: Option<(Submenu, Vec<String>, u64)>,
|
||||
}
|
||||
|
||||
impl MacMenu {
|
||||
pub fn new(ctx: &egui::Context) -> MacMenu {
|
||||
let (tx, rx) = channel();
|
||||
let repaint = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
|
||||
// AppKit calls this on the main thread, from the event that chose the item: a key
|
||||
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
|
||||
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
|
||||
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
|
||||
let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty());
|
||||
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
|
||||
.map(|m| flags.contains(m));
|
||||
let _ = tx.send(Chosen { id: e.id.0, key, mods });
|
||||
// Wake egui, so the item runs now rather than on the next mouse move.
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0, window: None }
|
||||
}
|
||||
|
||||
fn build(&mut self, bar: &MenuBar) {
|
||||
if let Some(old) = self.menu.take() {
|
||||
old.remove_for_nsapp();
|
||||
}
|
||||
self.entries.clear();
|
||||
self.window = None;
|
||||
let menu = Menu::new();
|
||||
let mut special = Vec::new();
|
||||
for m in &bar.menus {
|
||||
let (sub, ids) = self.submenu(m);
|
||||
let _ = menu.append(&sub);
|
||||
if m.role == MenuRole::Window {
|
||||
self.window = Some((sub.clone(), ids, native_menu::window_key(bar)));
|
||||
}
|
||||
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
|
||||
special.push((m.role, sub));
|
||||
}
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
|
||||
for (role, sub) in special {
|
||||
match role {
|
||||
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
|
||||
_ => sub.set_as_help_menu_for_nsapp(),
|
||||
}
|
||||
}
|
||||
self.menu = Some(menu);
|
||||
self.structure = native_menu::structure_key(bar);
|
||||
}
|
||||
|
||||
/// A top-level menu, and the muda ids of the items it added.
|
||||
fn submenu(&mut self, m: &native_menu::Menu) -> (Submenu, Vec<String>) {
|
||||
let before: HashSet<String> = self.entries.keys().cloned().collect();
|
||||
let sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
let ids = self.entries.keys().filter(|id| !before.contains(*id)).cloned().collect();
|
||||
(sub, ids)
|
||||
}
|
||||
|
||||
/// Replace the Window menu (a document opened or closed), leaving the other menus as they are.
|
||||
fn rebuild_window(&mut self, bar: &MenuBar) {
|
||||
let (Some(menu), Some((old, ids, _))) = (self.menu.clone(), self.window.take()) else { return self.build(bar) };
|
||||
let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else { return self.build(bar) };
|
||||
if menu.remove(&old).is_err() {
|
||||
return self.build(bar);
|
||||
}
|
||||
for id in ids {
|
||||
self.entries.remove(&id);
|
||||
}
|
||||
let (sub, ids) = self.submenu(spec);
|
||||
if menu.insert(&sub, at).is_err() {
|
||||
return self.build(bar);
|
||||
}
|
||||
sub.set_as_windows_menu_for_nsapp();
|
||||
self.window = Some((sub, ids, native_menu::window_key(bar)));
|
||||
}
|
||||
|
||||
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
Node::Separator => {
|
||||
let _ = sub.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Node::Submenu { label, children, .. } => {
|
||||
let s = Submenu::new(escape(label), !children.is_empty());
|
||||
self.append(&s, children);
|
||||
let _ = sub.append(&s);
|
||||
}
|
||||
Node::Standard(item) => {
|
||||
let p = match item {
|
||||
Standard::Services => PredefinedMenuItem::services(Some(photocraft_ui_egui::i18n::t("Services"))),
|
||||
Standard::HideOthers => PredefinedMenuItem::hide_others(Some(photocraft_ui_egui::i18n::t("Hide Others"))),
|
||||
Standard::ShowAll => PredefinedMenuItem::show_all(Some(photocraft_ui_egui::i18n::t("Show All"))),
|
||||
Standard::Minimize => PredefinedMenuItem::minimize(None),
|
||||
Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))),
|
||||
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
|
||||
Standard::CloseWindow => PredefinedMenuItem::close_window(None),
|
||||
};
|
||||
let _ = sub.append(&p);
|
||||
}
|
||||
Node::Item(it) => {
|
||||
let id = self.unique_id(&it.id);
|
||||
let chord = it.shortcut.as_deref().and_then(Chord::parse);
|
||||
let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator);
|
||||
let handle = match it.checked {
|
||||
Some(c) => {
|
||||
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Check(h)
|
||||
}
|
||||
None => {
|
||||
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
|
||||
let _ = sub.append(&h);
|
||||
Handle::Plain(h)
|
||||
}
|
||||
};
|
||||
self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`.
|
||||
fn unique_id(&self, id: &str) -> String {
|
||||
let mut unique = id.to_string();
|
||||
let mut n = 2;
|
||||
while self.entries.contains_key(&unique) {
|
||||
unique = format!("{id}#{n}");
|
||||
n += 1;
|
||||
}
|
||||
unique
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for MacMenu {
|
||||
fn sync(&mut self, bar: &MenuBar) {
|
||||
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure {
|
||||
self.build(bar);
|
||||
return;
|
||||
}
|
||||
if self.window.as_ref().map(|w| w.2) != Some(native_menu::window_key(bar)) {
|
||||
self.rebuild_window(bar);
|
||||
}
|
||||
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
|
||||
for e in self.entries.values_mut() {
|
||||
let Some(it) = items.get(e.command.as_str()) else { continue };
|
||||
if e.label != it.label {
|
||||
let text = escape(&it.label);
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_text(text),
|
||||
Handle::Check(h) => h.set_text(text),
|
||||
}
|
||||
e.label = it.label.clone();
|
||||
}
|
||||
if e.enabled != it.enabled {
|
||||
match &e.handle {
|
||||
Handle::Plain(h) => h.set_enabled(it.enabled),
|
||||
Handle::Check(h) => h.set_enabled(it.enabled),
|
||||
}
|
||||
e.enabled = it.enabled;
|
||||
}
|
||||
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
|
||||
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
|
||||
&& h.is_checked() != c
|
||||
{
|
||||
h.set_checked(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(chosen) = self.rx.try_recv() {
|
||||
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
|
||||
let Some(e) = self.entries.get(&chosen.id) else { continue };
|
||||
if e.command == native_menu::HIDE {
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
NSApplication::sharedApplication(mtm).hide(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
|
||||
// menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit
|
||||
// matched on it); the modifiers are the ones held.
|
||||
let [cmd, ctrl, alt, shift] = chosen.mods;
|
||||
let key =
|
||||
e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() });
|
||||
out.push(match key {
|
||||
Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord),
|
||||
_ => Event::Click(e.command.clone()),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay).
|
||||
pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option<native_menu::NativeMenu> {
|
||||
let language = &app.session.prefs().interface.language;
|
||||
let lang = photocraft_ui_egui::i18n::Lang::from_pref(language);
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar;
|
||||
// Never crash for a menu: if AppKit refuses, keep the in-window menus.
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut menu = MacMenu::new(ctx);
|
||||
menu.sync(&bar);
|
||||
menu
|
||||
}));
|
||||
match built {
|
||||
Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))),
|
||||
Err(_) => {
|
||||
log::warn!("couldn't install the macOS menu bar; using the in-window menus");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `&` marks a mnemonic in muda labels.
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&&")
|
||||
}
|
||||
|
||||
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
|
||||
fn accelerator(c: &Chord) -> Option<Accelerator> {
|
||||
let mut mods = Modifiers::empty();
|
||||
for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] {
|
||||
if on {
|
||||
mods |= m;
|
||||
}
|
||||
}
|
||||
const LETTERS: [Code; 26] = [
|
||||
Code::KeyA,
|
||||
Code::KeyB,
|
||||
Code::KeyC,
|
||||
Code::KeyD,
|
||||
Code::KeyE,
|
||||
Code::KeyF,
|
||||
Code::KeyG,
|
||||
Code::KeyH,
|
||||
Code::KeyI,
|
||||
Code::KeyJ,
|
||||
Code::KeyK,
|
||||
Code::KeyL,
|
||||
Code::KeyM,
|
||||
Code::KeyN,
|
||||
Code::KeyO,
|
||||
Code::KeyP,
|
||||
Code::KeyQ,
|
||||
Code::KeyR,
|
||||
Code::KeyS,
|
||||
Code::KeyT,
|
||||
Code::KeyU,
|
||||
Code::KeyV,
|
||||
Code::KeyW,
|
||||
Code::KeyX,
|
||||
Code::KeyY,
|
||||
Code::KeyZ,
|
||||
];
|
||||
const DIGITS: [Code; 10] =
|
||||
[Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9];
|
||||
const F: [Code; 24] = [
|
||||
Code::F1,
|
||||
Code::F2,
|
||||
Code::F3,
|
||||
Code::F4,
|
||||
Code::F5,
|
||||
Code::F6,
|
||||
Code::F7,
|
||||
Code::F8,
|
||||
Code::F9,
|
||||
Code::F10,
|
||||
Code::F11,
|
||||
Code::F12,
|
||||
Code::F13,
|
||||
Code::F14,
|
||||
Code::F15,
|
||||
Code::F16,
|
||||
Code::F17,
|
||||
Code::F18,
|
||||
Code::F19,
|
||||
Code::F20,
|
||||
Code::F21,
|
||||
Code::F22,
|
||||
Code::F23,
|
||||
Code::F24,
|
||||
];
|
||||
let k = c.key.as_str();
|
||||
let mut chars = k.chars();
|
||||
let code = match (chars.next(), chars.next()) {
|
||||
(Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?,
|
||||
(Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?,
|
||||
_ if c.is_function_key() => *F.get(k.get(1..)?.parse::<usize>().ok()?.checked_sub(1)?)?,
|
||||
_ => match k {
|
||||
"[" => Code::BracketLeft,
|
||||
"]" => Code::BracketRight,
|
||||
"\\" => Code::Backslash,
|
||||
"/" => Code::Slash,
|
||||
"=" => Code::Equal,
|
||||
"-" => Code::Minus,
|
||||
"'" => Code::Quote,
|
||||
";" => Code::Semicolon,
|
||||
"," => Code::Comma,
|
||||
"." => Code::Period,
|
||||
"`" => Code::Backquote,
|
||||
"Delete" | "Backspace" => Code::Backspace,
|
||||
"Enter" if !mods.is_empty() => Code::Enter,
|
||||
"Tab" if !mods.is_empty() => Code::Tab,
|
||||
"Space" if !mods.is_empty() => Code::Space,
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(Accelerator::new(mods, code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys).
|
||||
#[test]
|
||||
fn every_menu_shortcut_maps_to_a_key_equivalent() {
|
||||
let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar;
|
||||
let mut native = 0;
|
||||
for it in bar.items() {
|
||||
let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue };
|
||||
if c.native_ok() {
|
||||
assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id);
|
||||
native += 1;
|
||||
}
|
||||
}
|
||||
assert!(native > 70, "{native} key equivalents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_and_command_stay_distinct() {
|
||||
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
|
||||
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! macOS window lifecycle: title bar dragging and quitting.
|
||||
//!
|
||||
//! Dragging: the window draws under a transparent title bar (`main.rs`), so AppKit would
|
||||
//! otherwise drag the window from anywhere in that strip, menus included: pressing a menu title
|
||||
//! and dragging down to an item moved the window instead. Turning AppKit's own dragging off leaves
|
||||
//! only the app's drag region (the free gap between the menus and the controls,
|
||||
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
|
||||
//! `performWindowDragWithEvent:`, which works on an unmovable window).
|
||||
//!
|
||||
//! Quitting: see [`terminate_later`]. All safe objc2 calls.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
|
||||
/// Stop AppKit dragging the app's windows by their title bar strip.
|
||||
pub fn disable_native_title_drag() {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
|
||||
return;
|
||||
};
|
||||
for window in NSApplication::sharedApplication(mtm).windows().iter() {
|
||||
window.setMovable(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit the way a Mac app does, with `-[NSApplication terminate:]` (`Services::quit`), instead of
|
||||
/// letting eframe close the window. eframe drops the window while AppKit's run loop is still
|
||||
/// running, and on Macs with a Touch Bar AppKit's next display cycle then tries to stop observing
|
||||
/// a view that is gone: an uncaught exception that crashes (macOS 27) or leaves a windowless app
|
||||
/// that only Force Quit ends (macOS 26) (#1458, #1575). `terminate:` ends the process before
|
||||
/// another display cycle runs; winit's `applicationWillTerminate:` closes the windows and has
|
||||
/// eframe save the window layout first, as the old ⌘Q (winit's default menu) did.
|
||||
///
|
||||
/// Scheduled on the main queue: winit has to be out of its event handler, or eframe's exit
|
||||
/// handling (and the save) is dropped.
|
||||
pub fn terminate_later() {
|
||||
dispatch2::DispatchQueue::main().exec_async(|| match MainThreadMarker::new() {
|
||||
Some(mtm) => NSApplication::sharedApplication(mtm).terminate(None),
|
||||
None => log::warn!("quit: the main queue ran off the main thread; not terminating"),
|
||||
});
|
||||
}
|
||||
@@ -2,14 +2,7 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
//! macOS instead of the macOS menu bar (`mac_menu`).
|
||||
//! [--automation-write-root <dir>] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
@@ -20,37 +13,12 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod app_dirs;
|
||||
mod app_icon;
|
||||
mod appearance;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod caps_lock;
|
||||
mod caption_style;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod file_dialog_probe;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod icon_theme;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod photoshop_settings;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
@@ -58,225 +26,49 @@ use photocraft_ui_egui::PhotocraftApp;
|
||||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
|
||||
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
|
||||
/// keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
|
||||
/// Interface › System Title Bar asks for the system's (#1271, #1316). Read from the saved
|
||||
/// preferences before the window opens; a missing or unreadable file keeps the default.
|
||||
fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
let prefs: photocraft_engine::prefs::Preferences =
|
||||
prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
|
||||
}
|
||||
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
/// different offset every launch (#419). A later launch restores the saved position instead
|
||||
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
centered: true,
|
||||
// eframe saves native window geometry and egui panel/window sizes on exit.
|
||||
// Keep that state beside preferences, including config overrides and portable mode.
|
||||
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
|
||||
window_builder: Some(Box::new(restored_window)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
|
||||
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
|
||||
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
|
||||
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
|
||||
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
|
||||
/// Full Screen did nothing until `F` had cycled through Standard.
|
||||
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
|
||||
at_least_min_size(builder).with_fullscreen(false)
|
||||
}
|
||||
|
||||
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
|
||||
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
|
||||
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
|
||||
/// as a 160 × 64 strip on every launch.
|
||||
fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
|
||||
let (Some(size), Some(min)) = (builder.inner_size, builder.min_inner_size) else { return builder };
|
||||
let grown = size.max(min);
|
||||
if grown != size {
|
||||
builder.position = builder.position.map(|pos| pos - (grown - size) / 2.0);
|
||||
builder.inner_size = Some(grown);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
|
||||
/// names the value (issue #701): silently running with no control server leaves a launcher or
|
||||
/// agent unable to tell a typo from a successful grant.
|
||||
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
|
||||
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
|
||||
}
|
||||
|
||||
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
|
||||
/// when there are none, else 2 (a command-line usage error), never 0.
|
||||
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
|
||||
if errors.is_empty() { None } else { Some(2) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{control_args_exit_code, parse_control_port};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(control_args_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(control_args_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_accepts_valid_numbers() {
|
||||
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
|
||||
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
|
||||
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_unparseable_values_naming_them() {
|
||||
// Out of range, a following flag eaten as the value, and an empty value all fail,
|
||||
// naming the offending value in the message.
|
||||
let err = parse_control_port("78787", "--control").unwrap_err();
|
||||
assert!(err.contains("78787"), "{err}");
|
||||
assert!(parse_control_port("--safe-gpu", "--control").is_err());
|
||||
assert!(parse_control_port("", "--control").is_err());
|
||||
}
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
let mut control_port: Option<u16> = None;
|
||||
let mut control_arg_errors: Vec<String> = Vec::new();
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.to_str() {
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
},
|
||||
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(token)) => control_token = Some(token),
|
||||
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => control_token = None,
|
||||
},
|
||||
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--safe-gpu") => safe_gpu = true,
|
||||
Some("--in-window-menus") => in_window_menus = true,
|
||||
Some("--version") => {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--control-token" => control_token = args.next(),
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
}
|
||||
// Old macOS passes a process serial number when launched from Finder.
|
||||
Some(s) if s.starts_with("-psn_") => {}
|
||||
Some(s) => files.push(s.to_owned()),
|
||||
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701): name the
|
||||
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
|
||||
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = control_args_exit_code(&control_arg_errors) {
|
||||
for error in &control_arg_errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// and usage errors leave no file behind. Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
|
||||
// app no pen input (#639).
|
||||
#[cfg(target_os = "linux")]
|
||||
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = tablet::display_session();
|
||||
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
|
||||
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
|
||||
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = if session == linux_libs::DisplaySession::Wayland
|
||||
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
|
||||
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
|
||||
&& linux_libs::available(linux_libs::DisplaySession::X11)
|
||||
{
|
||||
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
|
||||
linux_libs::DisplaySession::X11
|
||||
} else {
|
||||
session
|
||||
};
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(message) = linux_libs::preflight(session) {
|
||||
eprint!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
eprintln!("photocraft: cannot configure control authentication: {e}");
|
||||
std::process::exit(2);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(path) = token_file {
|
||||
@@ -290,7 +82,7 @@ fn main() -> eframe::Result {
|
||||
Ok(workspace) => workspace,
|
||||
Err(error) => {
|
||||
eprintln!("photocraft: cannot configure automation workspace: {error}");
|
||||
std::process::exit(2);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
Some((port, token, workspace))
|
||||
@@ -304,169 +96,44 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
// The macOS pen tablet monitor (installed once eframe created the app, see below); it lives
|
||||
// until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = &tablet_monitor;
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
#[cfg(target_os = "linux")]
|
||||
let monitor = monitor_profile::detect_async_for_session(native_session);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
let monitor = monitor_profile::detect_async();
|
||||
// Brush presets load in the background; the app attaches them when they arrive.
|
||||
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
|
||||
let custom_titlebar = custom_titlebar(services::prefs_file().as_deref());
|
||||
let mut options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; open on X11 when that was chosen.
|
||||
#[cfg(target_os = "linux")]
|
||||
if session == linux_libs::DisplaySession::X11 {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
}));
|
||||
}
|
||||
// eframe restores the saved window layout before our code runs; drop values that would crash it.
|
||||
ui_state::sanitize(options.persistence_path.as_deref());
|
||||
// Centre only the first window: eframe centres after restoring the saved position, so a
|
||||
// window closed on another monitor reopened in the middle of the main one (#4826).
|
||||
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
let os = gpu_startup::Os::current();
|
||||
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
|
||||
let (previous, sentinel) = match services::config_dir() {
|
||||
Some(dir) => gpu_startup::Sentinel::begin(&dir),
|
||||
None => (gpu_startup::Previous::Clean, None),
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
let env_backend = std::env::var("WGPU_BACKEND").ok();
|
||||
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
if let Some(m) = previous.crashed() {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
|
||||
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_callback = app_created.clone();
|
||||
let started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"PhotoCraft",
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
eframe::run_native(
|
||||
"Photocraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
|
||||
let mut services = services::native(automation);
|
||||
services.preset_store = presets;
|
||||
#[cfg(target_os = "linux")]
|
||||
let display = tablet::DisplayKind::of(cc);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
|
||||
file_dialog_probe::start();
|
||||
if services.is_wayland {
|
||||
let var = |name| std::env::var(name).ok();
|
||||
services.xwayland_command =
|
||||
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
|
||||
}
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_window::disable_native_title_drag();
|
||||
// Long commands and file opens run as background jobs with progress and Cancel (#210).
|
||||
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
|
||||
// Displays and their profiles (#569): wait briefly so the first frames already use
|
||||
// the right profile; a slower reading is applied when it arrives, and the shell reads
|
||||
// again when the displays may have changed.
|
||||
if let Some(rx) = monitor {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
|
||||
}
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
|
||||
}
|
||||
}
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
(reason, note) => reason.or(note),
|
||||
};
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor.
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
|
||||
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
|
||||
.then(|| app.perf.gpu_info.fallback.clone())
|
||||
.flatten()
|
||||
});
|
||||
if let Some(reason) = fallback_reason {
|
||||
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// CPU rendering after a failed GPU start is temporary. Only the
|
||||
// recovery-dialog choice may persist it (#3483).
|
||||
let remember = gpu_startup::backend_to_remember(&plan);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
{
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -474,209 +141,10 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// Quit through AppKit, not by closing the window (Touch Bar Macs, #1575, #1458).
|
||||
app.services.quit = Some(Box::new(mac_window::terminate_later));
|
||||
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
|
||||
if !in_window_menus {
|
||||
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
// border offset the content and the pointer on some Windows systems (#2246).
|
||||
app.services.window_frame = window_frame::service(cc, custom_titlebar);
|
||||
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
|
||||
if custom_titlebar {
|
||||
app.services.caption_style = caption_style::service();
|
||||
}
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
|
||||
// write into the stylus feed. The monitor goes in here, not before the event loop:
|
||||
// AppKit's shared application only exists once winit created it (#759).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
|
||||
let _ = tablet_monitor.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
if !unreadable_paths.is_empty() {
|
||||
use photocraft_ui_egui::i18n;
|
||||
let line = i18n::t("{path}: the path is not valid Unicode; rename the file and open it again.");
|
||||
let lines = unreadable_paths.iter().map(|p| i18n::fmt(line, &[("path", p.as_str())])).collect();
|
||||
photocraft_ui_egui::notices::post(&mut app, i18n::t("Could not open"), lines, true, None);
|
||||
}
|
||||
// Portable marker found but its data folder isn't writable (#228): say where settings went.
|
||||
if let Some(w) = &app_dirs::current().warning {
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
|
||||
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
|
||||
// a failed CPU start would otherwise pin every later start to CPU.
|
||||
let renderer_error = gpu_startup::keep_marker_after_run(&result);
|
||||
if !(renderer_error && keep_marker)
|
||||
&& let Some(mut s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
{
|
||||
if result.is_err()
|
||||
&& !renderer_error
|
||||
&& let Some(marker) = previous.crashed()
|
||||
{
|
||||
// This failure supplies no new graphics-crash evidence: retain the previous
|
||||
// marker, rather than recording this attempt's fallback backend.
|
||||
if let Err(error) = s.write(marker.clone()) {
|
||||
log::warn!("couldn't restore previous GPU startup marker: {error}");
|
||||
}
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
}
|
||||
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
|
||||
// Only renderer initialization failures qualify; never restart after editing has begun.
|
||||
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
|
||||
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let launched = std::process::Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.arg("--safe-gpu")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
|
||||
.spawn();
|
||||
match launched {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn window_and_panel_geometry_survive_a_restart() {
|
||||
let options = super::native_options(super::CUSTOM_TITLEBAR);
|
||||
assert!(options.persist_window);
|
||||
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
|
||||
|
||||
#[derive(Default)]
|
||||
struct Storage(std::collections::BTreeMap<String, String>);
|
||||
impl eframe::Storage for Storage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut storage = Storage::default();
|
||||
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
|
||||
let restored = egui::Context::default();
|
||||
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
|
||||
let mut output = restored.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
|
||||
assert_eq!(panel.size().x, 410.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_opens_centred_at_its_default_size() {
|
||||
let o = super::native_options(super::CUSTOM_TITLEBAR);
|
||||
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
|
||||
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
|
||||
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
|
||||
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_while_minimized_reopens_at_the_minimum_size() {
|
||||
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
|
||||
// Saved minimized on Windows, then centred on a 1920 × 1080 monitor by eframe.
|
||||
let saved = super::native_options(super::CUSTOM_TITLEBAR).viewport.with_inner_size([160.0, 64.0]).with_position([880.0, 508.0]);
|
||||
let opened = hook(saved);
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 480.0)));
|
||||
assert_eq!(opened.position, Some(egui::pos2(580.0, 300.0)), "still centred");
|
||||
// Too narrow only: only the width grows.
|
||||
let opened = super::at_least_min_size(super::native_options(false).viewport.with_inner_size([500.0, 900.0]));
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 900.0)));
|
||||
// A window at or above the minimum, or with no size, is left alone.
|
||||
for size in [[760.0, 480.0], [1100.0, 760.0]] {
|
||||
let builder = super::native_options(false).viewport.with_inner_size(size).with_position([10.0, 20.0]);
|
||||
let opened = super::at_least_min_size(builder);
|
||||
assert_eq!((opened.inner_size, opened.position), (Some(egui::Vec2::from(size)), Some(egui::pos2(10.0, 20.0))));
|
||||
}
|
||||
let mut builder = super::native_options(false).viewport;
|
||||
builder.inner_size = None;
|
||||
assert_eq!(super::at_least_min_size(builder).inner_size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
|
||||
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
|
||||
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
|
||||
let saved = super::native_options(super::CUSTOM_TITLEBAR)
|
||||
.viewport
|
||||
.with_inner_size([2560.0, 1392.0])
|
||||
.with_position([0.0, 0.0])
|
||||
.with_fullscreen(true)
|
||||
.with_maximized(true);
|
||||
let opened = hook(saved);
|
||||
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
|
||||
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
|
||||
// system decorations on Windows and Linux; macOS always has them.
|
||||
let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default");
|
||||
std::fs::write(&file, "not json").unwrap();
|
||||
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
|
||||
std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap();
|
||||
assert!(!super::custom_titlebar(Some(&file)));
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
std::fs::write(&file, r#"{"interface":{"systemTitleBar":false}}"#).unwrap();
|
||||
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR);
|
||||
if super::CUSTOM_TITLEBAR {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
fn the_desktop_entry_names_the_window_class() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,48 +1,15 @@
|
||||
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`; #569).
|
||||
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
|
||||
//! Monitor Profile = `auto`).
|
||||
//!
|
||||
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
|
||||
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
|
||||
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
|
||||
//! thread; the shell starts one at launch and again when the displays may have changed
|
||||
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
|
||||
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
|
||||
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
|
||||
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
|
||||
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
|
||||
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
|
||||
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
|
||||
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
|
||||
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
|
||||
//! return `None` (sRGB, or the profile chosen in Color Settings).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use photocraft_engine::display_color::Display;
|
||||
|
||||
/// What the platform reader returns: the displays, or why there are none.
|
||||
pub type Detection = Result<Vec<Display>, String>;
|
||||
|
||||
/// Starts reading the displays in the background; `None` when the platform has no reader.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn detect_async() -> Option<Receiver<Detection>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(spawn_reader(detect))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux display profile reads are supported only for an X11 session. In particular, an app
|
||||
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
|
||||
/// compositor-managed output profile a second time.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
|
||||
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
|
||||
/// Starts reading the main display's profile in the background.
|
||||
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
@@ -50,354 +17,29 @@ fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
|
||||
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
|
||||
/// reals follows the user's locale, e.g. "0,0"). The id goes through NSNumber's `stringValue`:
|
||||
/// a display id above AppleScript's integer range (2^29) is otherwise coerced to a real and
|
||||
/// printed as "2.077748985E+9" (#1371).
|
||||
#[cfg(target_os = "macos")]
|
||||
const SCRIPT: &str = r#"use framework "AppKit"
|
||||
set out to ""
|
||||
repeat with s in (current application's NSScreen's screens() as list)
|
||||
set f to s's frame()
|
||||
set n to (((s's deviceDescription()'s objectForKey:"NSScreenNumber")'s stringValue()) as text)
|
||||
set icc to ""
|
||||
set cs to s's colorSpace()
|
||||
set csName to ""
|
||||
if cs is not missing value then
|
||||
set csName to ((cs's localizedName()) as text)
|
||||
set d to cs's ICCProfileData()
|
||||
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
|
||||
end if
|
||||
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
|
||||
end repeat
|
||||
return out"#;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect() -> Detection {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/osascript");
|
||||
cmd.args(["-e", SCRIPT]);
|
||||
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
let out = std::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"use framework \"AppKit\"",
|
||||
"-e",
|
||||
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
|
||||
])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36.
|
||||
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
|
||||
}
|
||||
|
||||
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Run the helper and return its standard output; a helper that fails, or runs longer than
|
||||
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
use std::process::Stdio;
|
||||
let mut child =
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
|
||||
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
|
||||
// the helper until the deadline.
|
||||
let drain = |p: Option<Box<dyn Read + Send>>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
if let Some(mut p) = p {
|
||||
let _ = p.read_to_end(&mut buf);
|
||||
}
|
||||
buf
|
||||
})
|
||||
};
|
||||
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("waiting for the display profile reader failed: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let out = out.join().unwrap_or_default();
|
||||
let err = err.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
let err = String::from_utf8_lossy(&err);
|
||||
let first = err.lines().next().unwrap_or("").trim();
|
||||
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect() -> Detection {
|
||||
linux::detect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use photocraft_engine::display_color::Display;
|
||||
use x11rb::connection::Connection as _;
|
||||
use x11rb::protocol::randr;
|
||||
use zbus::blocking::{connection::Builder, proxy::Proxy};
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
use super::Detection;
|
||||
|
||||
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
|
||||
const COLORD: &str = "org.freedesktop.ColorManager";
|
||||
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
|
||||
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
|
||||
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
|
||||
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
|
||||
|
||||
struct IccProfile {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub(super) fn detect() -> Detection {
|
||||
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
|
||||
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
|
||||
let resources = randr::get_screen_resources_current(&connection, root)
|
||||
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
|
||||
|
||||
let mut output_names = HashMap::new();
|
||||
let mut active_crtcs = BTreeMap::new();
|
||||
for output in resources.outputs {
|
||||
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
|
||||
Ok(cookie) => match cookie.reply() {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't read one XRandR output: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::debug!("couldn't request one XRandR output: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = String::from_utf8(info.name) else {
|
||||
log::debug!("skipping an XRandR output with a non-UTF-8 name");
|
||||
continue;
|
||||
};
|
||||
if !name.is_empty() {
|
||||
output_names.insert(output, name);
|
||||
active_crtcs.insert(info.crtc, ());
|
||||
}
|
||||
}
|
||||
|
||||
let profiles = match colord_profiles() {
|
||||
Ok(profiles) => profiles,
|
||||
Err(e) => {
|
||||
log::warn!("couldn't read X11 display profiles from colord: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
let mut displays = Vec::with_capacity(active_crtcs.len());
|
||||
for crtc in active_crtcs.into_keys() {
|
||||
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
|
||||
Ok(cookie) => match cookie.reply() {
|
||||
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
|
||||
let Some(primary_name) = output_names.first() else { continue };
|
||||
let profile = unambiguous_clone_profile(&output_names, &profiles);
|
||||
displays.push(Display {
|
||||
id: crtc,
|
||||
name: primary_name.clone(),
|
||||
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
|
||||
profile_name: profile.as_ref().map(|p| p.name.clone()),
|
||||
icc: profile.map(|p| p.bytes),
|
||||
});
|
||||
}
|
||||
if displays.is_empty() {
|
||||
return Err("X11 reported no active RandR outputs".into());
|
||||
}
|
||||
Ok(displays)
|
||||
}
|
||||
|
||||
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
|
||||
let connection = Builder::system()
|
||||
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
|
||||
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
|
||||
let devices: Vec<OwnedObjectPath> =
|
||||
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
|
||||
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
|
||||
for device_path in devices {
|
||||
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
|
||||
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
|
||||
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
|
||||
// `*` is colord's default profile qualifier. A display without one is kept, with no
|
||||
// ICC, so the engine reports the normal sRGB fallback.
|
||||
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
|
||||
Ok(path) => path,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
|
||||
let filename: String = match profile.get_property::<String>("Filename") {
|
||||
Ok(filename) if !filename.is_empty() => filename,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(profile) = read_icc(Path::new(&filename)) else {
|
||||
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
|
||||
continue;
|
||||
};
|
||||
profiles.entry(output_name.clone()).or_default().push(profile);
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn read_icc(path: &Path) -> Option<IccProfile> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
|
||||
let bytes = checked_icc_bytes(bytes)?;
|
||||
let name = path.file_name()?.to_str()?.to_owned();
|
||||
Some(IccProfile { name, bytes: Arc::new(bytes) })
|
||||
}
|
||||
|
||||
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
|
||||
return None;
|
||||
}
|
||||
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
|
||||
if declared < 132 || declared > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
bytes.truncate(declared);
|
||||
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
|
||||
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
|
||||
}
|
||||
|
||||
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
|
||||
let mut selected: Option<&IccProfile> = None;
|
||||
for output in output_names {
|
||||
let candidates = profiles.get(output)?;
|
||||
let first = candidates.first()?;
|
||||
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
|
||||
return None;
|
||||
}
|
||||
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
|
||||
return None;
|
||||
}
|
||||
selected = Some(first);
|
||||
}
|
||||
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
|
||||
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let bytes = rgb.as_ref().clone();
|
||||
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
|
||||
|
||||
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
|
||||
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
|
||||
assert!(checked_icc_bytes(vec![0; 132]).is_none());
|
||||
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_outputs_use_only_one_unambiguous_profile() {
|
||||
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
|
||||
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
|
||||
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
|
||||
|
||||
let matching =
|
||||
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
|
||||
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
|
||||
|
||||
let differing =
|
||||
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
|
||||
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
|
||||
|
||||
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
|
||||
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
|
||||
|
||||
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
|
||||
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
/// (the coordinates window positions use). Lines that don't parse are skipped.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn parse_reply(out: &str) -> Detection {
|
||||
let mut displays = Vec::new();
|
||||
let mut primary_height = None;
|
||||
for line in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let f: Vec<&str> = line.splitn(8, '\t').collect();
|
||||
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
|
||||
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (display_id(id), num(x), num(y), num(w), num(h)) else { continue };
|
||||
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
|
||||
let top = *primary_height.get_or_insert(h) - (y + h);
|
||||
let name = name.trim();
|
||||
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
|
||||
let b64 = b64.trim();
|
||||
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
|
||||
// treated as no profile (the engine then says the display has none).
|
||||
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
|
||||
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
|
||||
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
|
||||
}
|
||||
if displays.is_empty() {
|
||||
// Only the start of the first field: a display line carries a whole base64 ICC profile,
|
||||
// which must not end up in a notice (#1371).
|
||||
let first = out.lines().next().unwrap_or("").split('\t').next().unwrap_or("").trim();
|
||||
let mut shown: String = first.chars().take(80).collect();
|
||||
if shown.len() < first.len() {
|
||||
shown.push('…');
|
||||
}
|
||||
return Err(if shown.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {shown}") });
|
||||
}
|
||||
Ok(displays)
|
||||
}
|
||||
|
||||
/// A display id: a plain integer, or the scientific notation AppleScript printed for ids above
|
||||
/// its integer range before the helper asked for `stringValue` (#1371), when it is exact.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn display_id(s: &str) -> Option<u32> {
|
||||
let s = s.trim();
|
||||
s.parse::<u32>().ok().or_else(|| {
|
||||
let v = s.parse::<f64>().ok().filter(|v| v.is_finite() && *v >= 0.0 && v.fract() == 0.0 && *v <= f64::from(u32::MAX))?;
|
||||
Some(v as u32)
|
||||
})
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn detect() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
|
||||
@@ -445,94 +87,12 @@ mod tests {
|
||||
assert!(base64_decode("a$==").is_none());
|
||||
}
|
||||
|
||||
/// The detected profile (when there is one) parses as an RGB profile.
|
||||
#[test]
|
||||
fn helper_replies() {
|
||||
// Only the ICC signature is checked here; parsing is the engine's job.
|
||||
let mut icc = vec![0u8; 132];
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
let b64 = base64_encode(&icc);
|
||||
// The #569 setup: built-in display primary, the external one right of it and higher up.
|
||||
let reply = format!(
|
||||
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
|
||||
);
|
||||
let d = parse_reply(&reply).unwrap();
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
|
||||
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
|
||||
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
|
||||
assert_eq!(d[1].icc.as_deref(), Some(&icc));
|
||||
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
|
||||
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
|
||||
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
|
||||
assert!(d[1].icc.is_none());
|
||||
// Garbage lines are skipped; nothing usable is an error.
|
||||
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
|
||||
assert!(parse_reply("").is_err());
|
||||
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_display_ids_and_short_errors() {
|
||||
// #1371: a CGDirectDisplayID above AppleScript's integer range came back as a real.
|
||||
assert_eq!(display_id("2077748985"), Some(2_077_748_985));
|
||||
assert_eq!(display_id(" 2.077748985E+9 "), Some(2_077_748_985));
|
||||
assert_eq!(display_id("4.294967295E+9"), Some(u32::MAX));
|
||||
for bad in ["4.3E+9", "-1", "1.5", "NaN", "inf", "", "x"] {
|
||||
assert_eq!(display_id(bad), None, "{bad}");
|
||||
}
|
||||
let d = parse_reply("2.077748985E+9\t0\t0\t1512\t982\t\tColor LCD\tBuilt-in Retina Display\n").unwrap();
|
||||
assert_eq!(d[0].id, 2_077_748_985);
|
||||
// A line that can't be read never puts its profile into the error.
|
||||
let profile = "A".repeat(4000);
|
||||
let e = parse_reply(&format!("bad id\t0\t0\t1\t1\t{profile}\tColor LCD\tX\n")).unwrap_err();
|
||||
assert!(e.contains("bad id") && !e.contains("AAAA") && e.len() < 120, "{e}");
|
||||
let e = parse_reply(&"Z".repeat(500)).unwrap_err();
|
||||
assert!(e.chars().count() < 120 && e.ends_with('…'), "{e}");
|
||||
}
|
||||
|
||||
// Drives `sleep` (found on PATH: NixOS and the Nix build sandbox have no /bin/sleep) and
|
||||
// /bin/sh; the helper itself only runs on macOS.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
|
||||
assert!(e.contains("took longer than"), "{e}");
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
|
||||
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
|
||||
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
assert_eq!(big.len(), 1 << 20);
|
||||
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("boom"), "{e}");
|
||||
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
|
||||
for i in 0..=c.len() {
|
||||
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
while !s.len().is_multiple_of(4) {
|
||||
s.push('=');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn detected_displays_parse() {
|
||||
if let Ok(displays) = detect() {
|
||||
for d in displays {
|
||||
let Some(icc) = d.icc else { continue };
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
|
||||
}
|
||||
fn detected_profile_parses() {
|
||||
if let Some(bytes) = detect() {
|
||||
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
|
||||
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Photoshop's live keyboard shortcut set on this machine, for the one-time offer to import it
|
||||
//! at first launch (`photocraft_ui_egui::kys_import::offer_import`).
|
||||
//!
|
||||
//! Photoshop keeps the set in use as `Keyboard Shortcuts.psp` in its per-version settings
|
||||
//! folder; it is the same XML a saved `.kys` holds, named after the set the user chose.
|
||||
//! macOS: `~/Library/Preferences/Adobe Photoshop <version> Settings/`. Windows:
|
||||
//! `%APPDATA%\Adobe\Adobe Photoshop <version>\Adobe Photoshop <version> Settings\`. Every
|
||||
//! installed version has one; the newest version wins (the number in the folder name, then
|
||||
//! the file's modification time), since that is the Photoshop the user runs. Linux has no
|
||||
//! Photoshop; a copied set is imported by hand (Edit › Keyboard Shortcuts).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The file is a few tens of kilobytes; anything far larger is not a shortcut set.
|
||||
const MAX_BYTES: u64 = 4 << 20;
|
||||
|
||||
/// The platform whose folder layout applies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Mac,
|
||||
Windows,
|
||||
Other,
|
||||
}
|
||||
|
||||
const fn this_platform() -> Platform {
|
||||
if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Platform::Windows
|
||||
} else {
|
||||
Platform::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `Adobe Photoshop <version> Settings` folder that could hold a set, newest first.
|
||||
pub fn settings_dirs(os: Platform, env: &impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let mut dirs = match os {
|
||||
Platform::Mac => var("HOME").map(|h| h.join("Library/Preferences")).into_iter().flat_map(|prefs| settings_in(&prefs)).collect::<Vec<_>>(),
|
||||
// `%APPDATA%\Adobe\Adobe Photoshop 2026\Adobe Photoshop 2026 Settings`.
|
||||
Platform::Windows => var("APPDATA")
|
||||
.map(|a| a.join("Adobe"))
|
||||
.into_iter()
|
||||
.flat_map(|adobe| std::fs::read_dir(adobe).into_iter().flatten().flatten().map(|e| e.path()))
|
||||
.filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("Adobe Photoshop ")))
|
||||
.flat_map(|p| settings_in(&p))
|
||||
.collect(),
|
||||
Platform::Other => Vec::new(),
|
||||
};
|
||||
dirs.sort_by_key(|d| std::cmp::Reverse(version_key(d)));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// `Adobe Photoshop <version> Settings` folders directly inside `parent`.
|
||||
fn settings_in(parent: &Path) -> Vec<PathBuf> {
|
||||
std::fs::read_dir(parent)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
name.starts_with("Adobe Photoshop ") && name.ends_with(" Settings") && p.is_dir()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Newest first: the version number in the folder name (2026; CC 2019 → 2019; CS6 → 6), then
|
||||
/// the set's modification time.
|
||||
fn version_key(dir: &Path) -> (u64, std::time::SystemTime) {
|
||||
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let number = name.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).filter_map(|s| s.parse::<u64>().ok()).max().unwrap_or(0);
|
||||
let modified = std::fs::metadata(dir.join(FILE)).and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
(number, modified)
|
||||
}
|
||||
|
||||
/// The live set's file name inside a settings folder.
|
||||
pub const FILE: &str = "Keyboard Shortcuts.psp";
|
||||
|
||||
/// The newest install's live set as (path, XML text), `None` without a readable one. A file that
|
||||
/// is not a shortcut set (another version's binary `.psp`, a truncated file) is skipped.
|
||||
pub fn live_keyboard_shortcuts_on(os: Platform, env: &impl Fn(&str) -> Option<OsString>) -> Option<(String, String)> {
|
||||
settings_dirs(os, env).into_iter().map(|d| d.join(FILE)).find_map(|path| {
|
||||
let len = std::fs::metadata(&path).ok()?.len();
|
||||
if len > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
text.contains("<photoshop-keyboard-shortcuts").then(|| (path.to_string_lossy().into_owned(), text))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`live_keyboard_shortcuts_on`] for this platform.
|
||||
pub fn live_keyboard_shortcuts(env: &impl Fn(&str) -> Option<OsString>) -> Option<(String, String)> {
|
||||
live_keyboard_shortcuts_on(this_platform(), env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SET: &str = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<photoshop-keyboard-shortcuts version=\"4\" filename=\"texcuts\" modified=\"1\" multi-undo=\"1\">\n</photoshop-keyboard-shortcuts>\n";
|
||||
|
||||
/// A fresh scratch directory per test (removed first, so a rerun starts clean).
|
||||
fn temp_root(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-photoshop-settings-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write(dir: &Path, text: &[u8]) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(FILE), text).unwrap();
|
||||
}
|
||||
|
||||
fn env_for(root: &Path) -> impl Fn(&str) -> Option<OsString> + '_ {
|
||||
move |k| match k {
|
||||
"HOME" => Some(root.join("home").into_os_string()),
|
||||
"APPDATA" => Some(root.join("appdata").into_os_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_version_wins_on_macos() {
|
||||
let root = temp_root("mac");
|
||||
let prefs = root.join("home/Library/Preferences");
|
||||
write(&prefs.join("Adobe Photoshop 2024 Settings"), SET.as_bytes());
|
||||
write(&prefs.join("Adobe Photoshop 2026 Settings"), SET.replace("texcuts", "newest").as_bytes());
|
||||
write(&prefs.join("Adobe Photoshop CC 2019 Settings"), SET.as_bytes());
|
||||
write(&prefs.join("Adobe Photoshop 2025 Paths"), SET.as_bytes()); // not a Settings folder
|
||||
let env = env_for(&root);
|
||||
let dirs: Vec<String> = settings_dirs(Platform::Mac, &env).iter().map(|d| d.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(dirs, ["Adobe Photoshop 2026 Settings", "Adobe Photoshop 2024 Settings", "Adobe Photoshop CC 2019 Settings"]);
|
||||
let (path, text) = live_keyboard_shortcuts_on(Platform::Mac, &env).unwrap();
|
||||
assert!(path.replace('\\', "/").ends_with("Adobe Photoshop 2026 Settings/Keyboard Shortcuts.psp"), "{path}");
|
||||
assert!(text.contains("filename=\"newest\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_layout_and_unreadable_sets_are_skipped() {
|
||||
let root = temp_root("win");
|
||||
let adobe = root.join("appdata/Adobe");
|
||||
// The newest install's file is not a shortcut set (binary); the older one is used.
|
||||
write(&adobe.join("Adobe Photoshop 2026/Adobe Photoshop 2026 Settings"), &[0x80, 0x00, 0xff, 0xfe]);
|
||||
write(&adobe.join("Adobe Photoshop 2025/Adobe Photoshop 2025 Settings"), SET.as_bytes());
|
||||
std::fs::create_dir_all(adobe.join("Adobe Illustrator 30/Adobe Illustrator 30 Settings")).unwrap();
|
||||
let env = env_for(&root);
|
||||
let (path, _) = live_keyboard_shortcuts_on(Platform::Windows, &env).unwrap();
|
||||
assert!(path.replace('\\', "/").ends_with("Adobe Photoshop 2025/Adobe Photoshop 2025 Settings/Keyboard Shortcuts.psp"), "{path}");
|
||||
assert!(live_keyboard_shortcuts_on(Platform::Other, &env).is_none());
|
||||
assert!(live_keyboard_shortcuts_on(Platform::Mac, &|_| None).is_none(), "no HOME, no set");
|
||||
}
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
|
||||
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
|
||||
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
|
||||
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
|
||||
return None;
|
||||
}
|
||||
let window = cc.winit_window()?.clone();
|
||||
Some(Box::new(move |ctx| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let fallback = tx.clone();
|
||||
let placements: Vec<Placement> = window
|
||||
.available_monitors()
|
||||
.enumerate()
|
||||
.map(|(index, m)| {
|
||||
let p = m.position();
|
||||
let size = m.size();
|
||||
Placement { index, x: p.x, y: p.y, width: size.width, height: size.height }
|
||||
})
|
||||
.collect();
|
||||
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stop = cancelled.clone();
|
||||
let scale = ctx.native_pixels_per_point().unwrap_or(1.0);
|
||||
let wake = ctx.clone();
|
||||
if let Err(e) = std::thread::Builder::new().name("screen-color-picker".into()).spawn(move || {
|
||||
let result = capture(wayland, scale, &stop, &placements);
|
||||
let _ = tx.send(result);
|
||||
wake.request_repaint();
|
||||
}) {
|
||||
let _ = fallback.send(Err(format!("Could not start screen color picker: {e}")));
|
||||
}
|
||||
Pending { receiver: rx, cancelled }
|
||||
}))
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Placement {
|
||||
index: usize,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
|
||||
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
|
||||
}
|
||||
fn count(width: usize, height: usize) -> Result<usize, String> {
|
||||
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let profile = match photocraft_cms::Profile::parse(&bytes) {
|
||||
Ok(profile) => profile,
|
||||
Err(_) => return None,
|
||||
};
|
||||
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
|
||||
use x11rb::protocol::xproto::ConnectionExt as _;
|
||||
let cookie = match conn.intern_atom(true, name.as_bytes()) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let atom = match cookie.reply() {
|
||||
Ok(reply) => reply.atom,
|
||||
Err(_) => return None,
|
||||
};
|
||||
(atom != 0).then_some(atom)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
|
||||
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
|
||||
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let reply = match cookie.reply() {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
|
||||
return None;
|
||||
}
|
||||
icc_profile(reply.data)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
|
||||
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
|
||||
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
|
||||
Ok(cookie) => cookie,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let reply = match cookie.reply() {
|
||||
Ok(reply) => reply,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if reply.format != 8 || reply.bytes_after != 0 {
|
||||
return None;
|
||||
}
|
||||
icc_profile(reply.value)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
if wayland {
|
||||
return portal(stop);
|
||||
}
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
image::{Image, PixelLayout},
|
||||
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
|
||||
};
|
||||
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
|
||||
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
|
||||
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
count(usize::from(w), usize::from(h))?;
|
||||
let mut monitors = conn.randr_get_monitors(screen.root, true).ok().and_then(|c| c.reply().ok()).map(|r| r.monitors).unwrap_or_default();
|
||||
monitors.sort_by_key(|m| !m.primary);
|
||||
if !monitors.iter().any(|m| m.primary)
|
||||
&& let Some(first) = monitors.first_mut()
|
||||
{
|
||||
first.primary = true;
|
||||
}
|
||||
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
|
||||
vec![(0, 0, w, h, true, Vec::new())]
|
||||
} else {
|
||||
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
|
||||
};
|
||||
if regions.len() > 16 {
|
||||
return Err("Too many screens for the color picker".into());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for &(x, y, mw, mh, _, _) in ®ions {
|
||||
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
|
||||
return Err("Invalid X11 display geometry".into());
|
||||
}
|
||||
total = total
|
||||
.checked_add(count(usize::from(mw), usize::from(mh))?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let (image, visual_id) = Image::get(&conn, screen.root, 0, 0, w, h).map_err(|e| format!("Screen capture failed: {e}"))?;
|
||||
let visual = screen.allowed_depths.iter().flat_map(|d| &d.visuals).find(|v| v.visual_id == visual_id).ok_or("Unsupported X11 screen visual")?;
|
||||
if visual.class != VisualClass::TRUE_COLOR {
|
||||
return Err("Screen picking requires an X11 TrueColor visual".into());
|
||||
}
|
||||
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
|
||||
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
|
||||
let mut images = Vec::new();
|
||||
for (x, y, mw, mh, primary, outputs) in regions {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
|
||||
if !primary {
|
||||
return None;
|
||||
}
|
||||
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
|
||||
});
|
||||
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
|
||||
for row in 0..mh {
|
||||
for col in 0..mw {
|
||||
let (r, g, b) = layout.decode(image.get_pixel((x as u16) + col, (y as u16) + row));
|
||||
rgba.extend_from_slice(&[byte(r), byte(g), byte(b), 255]);
|
||||
}
|
||||
}
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, i32::from(x), i32::from(y), u32::from(mw), u32::from(mh)),
|
||||
position: egui::pos2(f32::from(x), f32::from(y)) / scale,
|
||||
size: egui::vec2(f32::from(mw), f32::from(mh)) / scale,
|
||||
width: usize::from(mw),
|
||||
height: usize::from(mh),
|
||||
rgba,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal(stop: &std::sync::atomic::AtomicBool) -> Result<Capture, String> {
|
||||
use std::collections::HashMap;
|
||||
use zbus::{
|
||||
blocking::Proxy,
|
||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|e| e.to_string())?
|
||||
.method_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("photocraft_{}", std::process::id());
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
|
||||
let request = Proxy::new(&conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
use futures_lite::StreamExt;
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let screenshot = Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let options: HashMap<&str, Value<'_>> = HashMap::from([("handle_token", Value::from(token.as_str()))]);
|
||||
let _handle: OwnedObjectPath =
|
||||
screenshot.call("PickColor", &("", options)).map_err(|e| format!("The desktop portal does not support color picking: {e}"))?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let reply = loop {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) || std::time::Instant::now() >= deadline {
|
||||
let _: Result<(), _> = request.call("Close", &());
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
|
||||
async_io::Timer::after(std::time::Duration::from_millis(50)).await;
|
||||
None
|
||||
}));
|
||||
if let Some(message) = next {
|
||||
break message.ok_or("The desktop color picker closed without a result")?;
|
||||
}
|
||||
};
|
||||
let (code, values): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
if code == 1 {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
if code != 0 {
|
||||
return Err("The desktop color picker could not sample this screen".into());
|
||||
}
|
||||
let value = values.get("color").ok_or("Desktop picker returned no color")?;
|
||||
let (r, g, b): (f64, f64, f64) = value.try_clone().map_err(|e| e.to_string())?.try_into().map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
if [r, g, b].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
|
||||
return Err("Desktop picker returned an invalid color".into());
|
||||
}
|
||||
Ok(Capture::Color(Some([r as f32, g as f32, b as f32])))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
let monitors = xcap::Monitor::all().map_err(|e| e.to_string())?;
|
||||
if monitors.is_empty() || monitors.len() > 16 {
|
||||
return Err("No supported screen is available".into());
|
||||
}
|
||||
let mut budget = 0usize;
|
||||
for m in &monitors {
|
||||
budget = budget
|
||||
.checked_add(count(m.width().map_err(|e| e.to_string())? as usize, m.height().map_err(|e| e.to_string())? as usize)?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut images = Vec::new();
|
||||
for m in monitors {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let (w, h) = (m.width().map_err(|e| e.to_string())?, m.height().map_err(|e| e.to_string())?);
|
||||
let scale = m.scale_factor().map_err(|e| e.to_string())?;
|
||||
if !scale.is_finite() || scale <= 0.0 || scale > 16.0 {
|
||||
return Err("Invalid display scale".into());
|
||||
}
|
||||
let captured = m.capture_image().map_err(|e| format!("Screen capture failed: {e}"))?;
|
||||
let (width, height) = (captured.width() as usize, captured.height() as usize);
|
||||
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
let (x, y) = (m.x().map_err(|e| e.to_string())?, m.y().map_err(|e| e.to_string())?);
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, x, y, w, h),
|
||||
position: egui::pos2(x as f32, y as f32) / scale,
|
||||
size: egui::vec2(w as f32, h as f32) / scale,
|
||||
width,
|
||||
height,
|
||||
rgba: captured.into_raw(),
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
use core_graphics2::{display::get_active_display_list, image::CGImageAlphaInfo as Alpha, window};
|
||||
if !window::preflight_screen_capture_access() && !window::request_screen_capture_access() {
|
||||
return Err("Allow PhotoCraft in System Settings > Privacy & Security > Screen Recording, then reopen PhotoCraft".into());
|
||||
}
|
||||
let displays = get_active_display_list(17).ok_or("Could not enumerate displays")?;
|
||||
if displays.is_empty() || displays.len() > 16 {
|
||||
return Err("No supported screen is available".into());
|
||||
}
|
||||
let mut budget = 0usize;
|
||||
for display in &displays {
|
||||
let mode = display.copy_display_mode().ok_or("Display mode is unavailable")?;
|
||||
budget = budget
|
||||
.checked_add(count(mode.pixel_width(), mode.pixel_height())?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let mut images = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for display in displays {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
// Capture a composed monitor at native resolution through safe bindings. Keeping the
|
||||
// CoreGraphics seam preserves PhotoCraft's existing macOS 11 deployment target.
|
||||
let bounds = display.bounds();
|
||||
let image = window::new_image(
|
||||
bounds,
|
||||
window::CGWindowListOption::OnScreenOnly,
|
||||
window::kCGNullWindowID,
|
||||
window::CGWindowImageOption::BestResolution | window::CGWindowImageOption::ShouldBeOpaque,
|
||||
)
|
||||
.ok_or("Screen capture failed; check Screen Recording permission")?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
let profile = image.color_space().and_then(|space| space.copy_icc_data());
|
||||
if profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024) {
|
||||
return Err("Screen profile exceeds the memory budget".into());
|
||||
}
|
||||
let info = image.bitmap_info().bits();
|
||||
if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 || info & (1 << 8) != 0 {
|
||||
return Err("Unsupported screen capture pixel format".into());
|
||||
}
|
||||
let (first, premultiplied, opaque) = match image.alpha_info() {
|
||||
Alpha::AlphaPremultipliedFirst => (true, true, false),
|
||||
Alpha::AlphaPremultipliedLast => (false, true, false),
|
||||
Alpha::AlphaFirst => (true, false, false),
|
||||
Alpha::AlphaLast => (false, false, false),
|
||||
Alpha::AlphaNoneSkipFirst => (true, false, true),
|
||||
Alpha::AlphaNoneSkipLast => (false, false, true),
|
||||
_ => return Err("Unsupported screen capture alpha layout".into()),
|
||||
};
|
||||
let little = match info & 0x7000 {
|
||||
0 => cfg!(target_endian = "little"),
|
||||
0x2000 => true,
|
||||
0x4000 => false,
|
||||
_ => return Err("Unsupported screen capture byte order".into()),
|
||||
};
|
||||
let order = match (first, little) {
|
||||
(true, true) => [2, 1, 0, 3],
|
||||
(true, false) => [1, 2, 3, 0],
|
||||
(false, true) => [3, 2, 1, 0],
|
||||
(false, false) => [0, 1, 2, 3],
|
||||
};
|
||||
let stride = image.bytes_per_row();
|
||||
checked_stride(width, height, stride)?;
|
||||
let bytes = image.data_provider().and_then(|p| p.copy_data()).ok_or("Screen image has no pixels")?;
|
||||
let rgba = unpack_rgba(bytes.bytes(), width, height, stride, order, premultiplied, opaque)?;
|
||||
let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
|
||||
let (x, y) = (bounds.origin.x as f32, bounds.origin.y as f32);
|
||||
if ![w, h, x, y].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||
return Err("Invalid display geometry".into());
|
||||
}
|
||||
let scale = width as f32 / w;
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, (x * scale).round() as i32, (y * scale).round() as i32, width as u32, height as u32),
|
||||
position: egui::pos2(x, y),
|
||||
size: egui::vec2(w, h),
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
profile: profile.map(|p| p.bytes().to_vec()),
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
// CoreGraphics rows can be padded and their byte order/alpha vary. Kept platform independent
|
||||
// so Linux CI also tests the macOS decoding path against synthetic pixels.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn checked_stride(width: usize, height: usize, stride: usize) -> Result<usize, String> {
|
||||
count(width, height)?;
|
||||
let row = width.checked_mul(4).ok_or("Invalid screen row")?;
|
||||
if stride < row || stride > row.saturating_add(4096) {
|
||||
return Err("Invalid screen row stride".into());
|
||||
}
|
||||
stride.checked_mul(height).filter(|&n| n <= MAX_PIXELS.saturating_mul(4)).ok_or_else(|| "Screen buffer exceeds the memory budget".into())
|
||||
}
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn unpack_rgba(bytes: &[u8], width: usize, height: usize, stride: usize, order: [usize; 4], premultiplied: bool, opaque: bool) -> Result<Vec<u8>, String> {
|
||||
let length = checked_stride(width, height, stride)?;
|
||||
if bytes.len() < length || order.iter().any(|&i| i > 3) {
|
||||
return Err("Invalid screen pixel buffer".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(count(width, height)?.saturating_mul(4));
|
||||
for row in bytes.chunks_exact(stride).take(height) {
|
||||
for p in row[..width * 4].as_chunks::<4>().0 {
|
||||
let a = if opaque { 255 } else { p[order[3]] };
|
||||
for &i in &order[..3] {
|
||||
let v = u32::from(p[i]);
|
||||
out.push(if premultiplied && a != 255 {
|
||||
if a == 0 { 0 } else { ((v * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }
|
||||
} else {
|
||||
v as u8
|
||||
});
|
||||
}
|
||||
out.push(255);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn screen_capture_decodes_padded_bgra_and_premultiplied_argb() {
|
||||
assert_eq!(
|
||||
unpack_rgba(&[10, 20, 30, 0, 99, 99, 99, 99, 40, 50, 60, 0, 99, 99, 99, 99], 1, 2, 8, [2, 1, 0, 3], false, true).unwrap(),
|
||||
[30, 20, 10, 255, 60, 50, 40, 255]
|
||||
);
|
||||
assert_eq!(unpack_rgba(&[128, 64, 32, 16], 1, 1, 4, [1, 2, 3, 0], true, false).unwrap(), [128, 64, 32, 255]);
|
||||
assert_eq!(unpack_rgba(&[0, 0, 0, 0], 1, 1, 4, [0, 1, 2, 3], true, false).unwrap(), [0, 0, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn screen_capture_rejects_unbounded_or_truncated_buffers() {
|
||||
assert!(count(usize::MAX, 2).is_err());
|
||||
assert!(checked_stride(1, 1, usize::MAX).is_err());
|
||||
assert!(checked_stride(1, MAX_PIXELS, 4096).is_err());
|
||||
assert!(unpack_rgba(&[0; 3], 1, 1, 4, [0, 1, 2, 3], false, true).is_err());
|
||||
assert!(unpack_rgba(&[0; 4], 1, 1, 4, [4, 1, 2, 3], false, true).is_err());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
|
||||
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
|
||||
let bytes = rgb.as_ref().clone();
|
||||
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
|
||||
|
||||
assert!(icc_profile(Vec::new()).is_none());
|
||||
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
|
||||
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
|
||||
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
|
||||
fn screen_capture_reads_a_known_x11_fixture_pixel() {
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection,
|
||||
protocol::xproto::{ConfigureWindowAux, ConnectionExt, CreateWindowAux, StackMode, WindowClass},
|
||||
};
|
||||
let (conn, index) = x11rb::connect(None).unwrap();
|
||||
let screen = &conn.setup().roots[index];
|
||||
let window = conn.generate_id().unwrap();
|
||||
conn.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
200,
|
||||
200,
|
||||
8,
|
||||
8,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new().override_redirect(1).background_pixel(0xff8040),
|
||||
)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
conn.map_window(window).unwrap().check().unwrap();
|
||||
conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).unwrap().check().unwrap();
|
||||
conn.flush().unwrap();
|
||||
// X11 request completion precedes the compositor presenting the mapped fixture.
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let images = capture(false, 1.0, &std::sync::atomic::AtomicBool::new(false), &[]);
|
||||
conn.destroy_window(window).unwrap().check().unwrap();
|
||||
conn.flush().unwrap();
|
||||
let Capture::Images(images) = images.unwrap() else { panic!("not an image capture") };
|
||||
let first = images.iter().find(|m| m.position.x <= 200.0 && m.position.y <= 200.0).unwrap();
|
||||
let x = (200.0 - first.position.x) as usize;
|
||||
let y = (200.0 - first.position.y) as usize;
|
||||
let offset = (y * first.width + x) * 4;
|
||||
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
|
||||
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
|
||||
assert_eq!(find_monitor(&m, -3840, 0, 3840, 2160), Some(2));
|
||||
assert_eq!(find_monitor(&m, 0, 0, 1920, 1080), None);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
fn capture(_wayland: bool, _scale: f32, _stop: &std::sync::atomic::AtomicBool, _placements: &[Placement]) -> Result<Capture, String> {
|
||||
Err("Screen color picking is unavailable on this platform".into())
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use super::*;
|
||||
use photocraft_color::Color;
|
||||
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn snapshot(tag: &str) -> (PathBuf, Document, RecoveryEntry) {
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-deferred-recovery-{tag}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut doc = Document::with_background("Recovered drawing", Size::new(4, 3), ColorMode::Rgb, SampleType::U16, Color::WHITE);
|
||||
doc.layers[0].surface_mut().unwrap().write_pixel(1, 2, &[1.0, 0.0, 0.0, 1.0]);
|
||||
let saver = Autosaver::new(&dir, "previous-session");
|
||||
saver.request_checked(Arc::new(doc.clone()), 7, Some(dir.join("original.pcraft").to_string_lossy().into_owned()), SaveOptions::default()).unwrap();
|
||||
assert!(saver.flush().unwrap().unwrap().tiles_written > 0);
|
||||
let mut entries = list_recovery(&dir);
|
||||
assert_eq!(entries.len(), 1);
|
||||
(dir, doc, entries.pop().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_discovers_metadata_before_decoding_and_keeps_a_failed_snapshot() {
|
||||
let (dir, doc, entry) = snapshot("corrupt");
|
||||
let tile = std::fs::read_dir(entry.bundle.join("tiles")).unwrap().next().unwrap().unwrap().path();
|
||||
std::fs::write(&tile, b"corrupt compressed tile").unwrap();
|
||||
let sidecar = dir.join(format!("{}.json", entry.info.key));
|
||||
let metadata = std::fs::read(&sidecar).unwrap();
|
||||
let mut services = recovery_services(Some(dir.clone()));
|
||||
|
||||
let mut found = services.recover.as_mut().unwrap()();
|
||||
assert_eq!(found.len(), 1, "discovery lists metadata without decoding the corrupt pixels");
|
||||
let pending = found.pop().unwrap();
|
||||
assert_eq!(pending.key, entry.info.key);
|
||||
assert_eq!(pending.name, doc.name);
|
||||
assert_eq!(pending.path, entry.info.original_path);
|
||||
assert!((pending.load)().is_err(), "decoding is deferred until the loader runs");
|
||||
|
||||
assert_eq!(std::fs::read(&tile).unwrap(), b"corrupt compressed tile");
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), metadata);
|
||||
assert_eq!(list_recovery(&dir), vec![entry], "a failed recovery must leave the snapshot available");
|
||||
drop(services);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_recovery_preserves_pixels_and_metadata_until_the_adopted_entry_is_discarded() {
|
||||
let (dir, doc, entry) = snapshot("valid");
|
||||
let mut services = recovery_services(Some(dir.clone()));
|
||||
let mut found = services.recover.as_mut().unwrap()();
|
||||
assert_eq!(found.len(), 1);
|
||||
let pending = found.pop().unwrap();
|
||||
assert_eq!(pending.key, entry.info.key);
|
||||
assert_eq!(pending.name, doc.name);
|
||||
assert_eq!(pending.path, entry.info.original_path);
|
||||
|
||||
// The loader can move to a worker without taking the main thread's recovery store with it.
|
||||
let restored = std::thread::spawn(pending.load).join().unwrap().unwrap();
|
||||
assert_eq!(restored, doc);
|
||||
assert_eq!(restored.layers[0].surface().unwrap().pixel(1, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
services.adopt_autosave.as_mut().unwrap()(restored.id.0, &pending.key);
|
||||
assert_eq!(list_recovery(&dir), vec![entry.clone()], "loading and adopting retain the recovery files");
|
||||
|
||||
services.discard_autosave.as_mut().unwrap()(restored.id.0);
|
||||
assert!(list_recovery(&dir).is_empty());
|
||||
assert!(!entry.bundle.exists());
|
||||
assert!(!dir.join(format!("{}.json", pending.key)).exists());
|
||||
drop(services);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force.
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
//! `unsafe`, see the crate docs): there the pen does nothing in the window, not even move the
|
||||
//! pointer (#639). Xwayland binds it and serves the pen as an XInput2 device, so with a pen
|
||||
//! attached the app opens its window through Xwayland ([`display_session`]), where the X11 reader
|
||||
//! gets pressure, tilt and the eraser end.
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
use crate::linux_libs::DisplaySession;
|
||||
use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
|
||||
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
|
||||
/// returns. On failure this logs why: pens then paint like a mouse.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
|
||||
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// The display server to open the window on: the session's own, except Xwayland on Wayland when
|
||||
/// a pen is attached (see the module docs) and Xwayland can run the window: `$DISPLAY` is set
|
||||
/// and the X11 libraries are installed. `PHOTOCRAFT_NATIVE_WAYLAND=1` keeps native Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn display_session() -> DisplaySession {
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let session = crate::linux_libs::session_from_env(var);
|
||||
let pen = || std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|d| has_pen(&d));
|
||||
let x11_libs = || crate::linux_libs::available(DisplaySession::X11);
|
||||
if pen_needs_xwayland(session, var, pen, x11_libs) {
|
||||
eprintln!("photocraft: pen found; opening the window through Xwayland, where the pen works (PHOTOCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
|
||||
return DisplaySession::X11;
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
/// [`display_session`]'s decision; the system probes run only when the session asks for them.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<String>, pen: impl FnOnce() -> bool, x11_libs: impl FnOnce() -> bool) -> bool {
|
||||
let set = |k: &str| var(k).is_some_and(|v| !v.is_empty() && v != "0");
|
||||
session == DisplaySession::Wayland && !set("PHOTOCRAFT_NATIVE_WAYLAND") && set("DISPLAY") && pen() && x11_libs()
|
||||
}
|
||||
|
||||
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key a pen tablet's stylus, a pen display's or
|
||||
/// a pen-enabled touchscreen's reports (libinput treats such a device as a tablet tool).
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
|
||||
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_X: u32 = 0x00;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_Y: u32 = 0x01;
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
|
||||
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
|
||||
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
|
||||
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
|
||||
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
|
||||
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn has_pen(devices: &str) -> bool {
|
||||
devices.split("\n\n").any(|device| {
|
||||
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
|
||||
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
|
||||
/// significant first. Short or malformed bitmaps have no bits set.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn bit_set(bitmap: &str, bit: u32) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
|
||||
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
|
||||
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
|
||||
/// X app would outlive a mouse stroke here), so nothing starts.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
|
||||
if display != Some(DisplayKind::X11) {
|
||||
log::info!("tablet pressure: not available on this display server ({display:?})");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The windowing system eframe ended up on.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayKind {
|
||||
X11,
|
||||
Wayland,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl DisplayKind {
|
||||
/// From the creation context's display handle.
|
||||
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
|
||||
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
|
||||
Some(match cc.display_handle().ok()?.as_raw() {
|
||||
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
|
||||
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
|
||||
_ => DisplayKind::Other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_tablet::{Update, appkit, xi};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn samples_map_and_sanitize() {
|
||||
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
|
||||
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
|
||||
let feed = StylusFeed::default();
|
||||
let f = sink(feed.clone());
|
||||
f(Some(Sample { pressure: 0.5, ..Default::default() }));
|
||||
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
|
||||
f(None);
|
||||
assert_eq!(feed.get(), None);
|
||||
}
|
||||
|
||||
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
|
||||
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
|
||||
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
|
||||
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
|
||||
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let feed = app.stylus.feed.clone();
|
||||
let sink = sink(feed);
|
||||
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
|
||||
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
|
||||
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let n = 20;
|
||||
for i in 0..=n {
|
||||
let t = i as f32 / n as f32;
|
||||
sink(next(0.1 + 0.9 * t));
|
||||
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
|
||||
let pressure = app.stylus.pressure();
|
||||
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
|
||||
tool_event(&mut app, ev, m);
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
|
||||
sink(None);
|
||||
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
|
||||
// half-width there.
|
||||
let mut dark = |x: i32, y: i32| -> f64 {
|
||||
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
|
||||
1.0 - px[0].as_f64().unwrap()
|
||||
};
|
||||
let (light, heavy) = (dark(50, 40), dark(200, 40));
|
||||
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
|
||||
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
|
||||
(light, light_w, heavy, heavy_w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = appkit::State::default();
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
|
||||
let e =
|
||||
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
|
||||
|
||||
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
|
||||
let mut st = appkit::State::default();
|
||||
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
|
||||
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = xi::State::default();
|
||||
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
}
|
||||
|
||||
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
|
||||
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, BTN_TOOL_PEN) and of the same laptop without it.
|
||||
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
|
||||
N: Name=\"AT Translated Set 2 keyboard\"\n\
|
||||
H: Handlers=sysrq kbd event3 leds\n\
|
||||
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
|
||||
\n\
|
||||
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
|
||||
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
|
||||
H: Handlers=mouse1 event7\n\
|
||||
B: KEY=e520 10000 0 0 0 0\n\
|
||||
B: ABS=2e0800000000003\n\
|
||||
\n\
|
||||
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
|
||||
N: Name=\"Wacom Intuos Pro M Pen\"\n\
|
||||
H: Handlers=mouse2 event10\n\
|
||||
B: KEY=1c03 0 0 0 0 0\n\
|
||||
B: ABS=1000f000003\n";
|
||||
|
||||
#[test]
|
||||
fn a_pen_is_found_by_its_btn_tool_pen_key() {
|
||||
assert!(has_pen(WITH_PEN));
|
||||
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
|
||||
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
|
||||
// Short or malformed bitmaps are no pen, never a panic.
|
||||
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
|
||||
assert!(!has_pen(keys), "{keys:?}");
|
||||
}
|
||||
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
|
||||
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
|
||||
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
|
||||
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
|
||||
/// the device's own, from `/proc/bus/input/devices`.
|
||||
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
|
||||
N: Name=\"RustDesk UInput Keyboard\"\n\
|
||||
P: Phys=\n\
|
||||
S: Sysfs=/devices/virtual/input/input54\n\
|
||||
U: Uniq=\n\
|
||||
H: Handlers=sysrq rfkill kbd event256 leds \n\
|
||||
B: PROP=0\n\
|
||||
B: EV=20013\n\
|
||||
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
|
||||
B: MSC=10\n\
|
||||
B: LED=7\n";
|
||||
|
||||
#[test]
|
||||
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
|
||||
assert!(
|
||||
bit_set(
|
||||
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
|
||||
BTN_TOOL_PEN
|
||||
),
|
||||
"the fixture claims the key"
|
||||
);
|
||||
assert!(!has_pen(VIRTUAL_KEYBOARD));
|
||||
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
|
||||
// pen's device and the pen's axes never into the keyboard's.
|
||||
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
|
||||
assert!(has_pen(&both));
|
||||
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
|
||||
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
|
||||
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
|
||||
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
|
||||
assert!(has_pen(pen_display));
|
||||
// Axes without `ABS_X` or `ABS_Y` don't count.
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_on_wayland_opens_the_window_through_xwayland() {
|
||||
use std::collections::HashMap;
|
||||
let env = |pairs: &[(&str, &str)]| {
|
||||
let m: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k: &str| m.get(k).cloned()
|
||||
};
|
||||
let wayland = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")];
|
||||
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || true));
|
||||
// No pen, no Xwayland, no X11 libraries, or the user keeps Wayland: native Wayland.
|
||||
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || false, || true));
|
||||
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&[("WAYLAND_DISPLAY", "wayland-0")]), || true, || true));
|
||||
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || false));
|
||||
let native = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "1")];
|
||||
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&native), || true, || true));
|
||||
let zero = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "0")];
|
||||
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&zero), || true, || true));
|
||||
// X11 already gets the pen; the probes don't even run off Wayland.
|
||||
assert!(!pen_needs_xwayland(DisplaySession::X11, env(&[("DISPLAY", ":0")]), || panic!("probed"), || panic!("probed")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_tablet_pressure_off_paints_like_a_mouse() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
|
||||
// The canvas copies the preference each frame; do what it does.
|
||||
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
|
||||
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
|
||||
assert_eq!(app.stylus.pressure(), 1.0);
|
||||
assert_eq!(app.stylus.sample(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_end_switches_to_the_eraser_and_back() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let sink = sink(app.stylus.feed.clone());
|
||||
let mut st = appkit::State::default();
|
||||
let mut send = |e: appkit::RawEvent| {
|
||||
if let Update::Set(s) = st.handle(&e) {
|
||||
sink(s);
|
||||
}
|
||||
};
|
||||
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
|
||||
app.ui.tool = Tool::Brush;
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
|
||||
send(prox(appkit::device::PEN, false));
|
||||
send(prox(appkit::device::ERASER, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Eraser);
|
||||
// Mouse moves in between change nothing; the tip coming back restores the Brush.
|
||||
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush);
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
|
||||
//!
|
||||
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
|
||||
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
|
||||
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
|
||||
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
|
||||
//! holding such values (the window and panels open at their defaults; nothing else is lost).
|
||||
//! A file that isn't RON at all is left alone: eframe already ignores it.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
|
||||
const MAX_COORD: f64 = 1.0e6;
|
||||
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
|
||||
/// 300% on a 1× display is 3).
|
||||
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
|
||||
|
||||
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
|
||||
pub fn sanitize(path: Option<&Path>) {
|
||||
let Some(path) = path else { return };
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
if let Err(why) = check(&text) {
|
||||
log::warn!("ignoring saved window layout {}: {why}", path.display());
|
||||
if let Err(e) = std::fs::remove_file(path) {
|
||||
log::warn!("could not remove {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
|
||||
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
|
||||
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
|
||||
pub fn has_saved_position(path: Option<&Path>) -> bool {
|
||||
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
|
||||
saved_position(&text)
|
||||
}
|
||||
|
||||
fn saved_position(text: &str) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
|
||||
// eframe restores the inner position on macOS and the outer position elsewhere
|
||||
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
|
||||
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
|
||||
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
|
||||
}
|
||||
|
||||
/// `Err` names the first value that would crash startup.
|
||||
fn check(text: &str) -> Result<(), String> {
|
||||
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
|
||||
for (key, value) in &kv {
|
||||
// eframe also skips an entry that doesn't parse.
|
||||
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
|
||||
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
|
||||
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
|
||||
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
|
||||
return Err(format!("egui: zoom factor {z}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
|
||||
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
|
||||
if depth > 64 {
|
||||
return Err("nested too deeply".into());
|
||||
}
|
||||
match v {
|
||||
ron::Value::Number(_) => match as_f64(v) {
|
||||
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
|
||||
_ => Ok(()),
|
||||
},
|
||||
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
|
||||
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
|
||||
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A float (integers such as egui's hashed ids are not coordinates).
|
||||
fn as_f64(v: &ron::Value) -> Option<f64> {
|
||||
match v {
|
||||
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
|
||||
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
match v {
|
||||
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, saved_position};
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
|
||||
}
|
||||
|
||||
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
|
||||
|
||||
#[test]
|
||||
fn a_saved_layout_is_kept() {
|
||||
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
|
||||
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_abort_startup_are_rejected() {
|
||||
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
|
||||
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
|
||||
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
|
||||
}
|
||||
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
|
||||
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
|
||||
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_position_is_detected() {
|
||||
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
|
||||
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
assert!(saved_position(&file(window, "1.0")));
|
||||
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
assert!(!saved_position(&file(no_position, "1.0")));
|
||||
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
|
||||
assert!(!saved_position(text), "{text:?}");
|
||||
}
|
||||
assert!(!super::has_saved_position(None));
|
||||
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_files_are_left_to_eframe() {
|
||||
// eframe ignores files and entries it can't parse, so they can't crash it.
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
|
||||
assert_eq!(check(text), Ok(()), "{text:?}");
|
||||
}
|
||||
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
|
||||
let _ = check(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_removes_only_a_bad_file() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("good.ron");
|
||||
let bad = dir.join("bad.ron");
|
||||
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
|
||||
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
|
||||
super::sanitize(Some(&good));
|
||||
super::sanitize(Some(&bad));
|
||||
super::sanitize(Some(&dir.join("missing.ron")));
|
||||
super::sanitize(None);
|
||||
assert!(good.exists() && !bad.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Keeps the custom (undecorated) title bar window truly borderless (#2246).
|
||||
//!
|
||||
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
|
||||
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems
|
||||
//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still
|
||||
//! reserves that frame for the window surface: the content is drawn offset by the caption and
|
||||
//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset
|
||||
//! client coordinates, so it no longer points at what is drawn under it.
|
||||
//!
|
||||
//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated
|
||||
//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and
|
||||
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
|
||||
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
|
||||
//!
|
||||
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
|
||||
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
|
||||
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
|
||||
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
|
||||
//! under ExplorerPatcher (#3144). The guard therefore also trims a parked maximize's rect back to
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
if !custom_titlebar {
|
||||
return None;
|
||||
}
|
||||
let target = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(h) => h.hwnd.get(),
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
reapply(&hwnd);
|
||||
Some(Box::new(move || reapply(&hwnd)))
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
|
||||
/// this matches winit's own window without wrapping a raw handle (which needs `unsafe`).
|
||||
fn find_window(target: isize) -> Option<HWND> {
|
||||
let mut found = None;
|
||||
let _ = winsafe::EnumWindows(|hwnd| {
|
||||
if hwnd.ptr() as isize == target {
|
||||
found = Some(hwnd);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
|
||||
/// maximize stays clear of the taskbar.
|
||||
fn reapply(hwnd: &HWND) {
|
||||
strip(hwnd);
|
||||
keep_clear_of_the_taskbar(hwnd);
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
|
||||
/// are gone, so it is cheap to call every frame.
|
||||
fn strip(hwnd: &HWND) {
|
||||
let style = hwnd.style();
|
||||
let without = borderless(style);
|
||||
if without.raw() == style.raw() {
|
||||
return;
|
||||
}
|
||||
hwnd.set_style(without);
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
|
||||
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
|
||||
/// parked but being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
}
|
||||
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let Some((left, top, right, bottom)) =
|
||||
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
|
||||
return;
|
||||
};
|
||||
// Moving the outer frame while winit pins the client rect to the work area must
|
||||
// not copy the old frame's pixels into the custom title bar.
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT { x: left, y: top },
|
||||
SIZE { cx: width, cy: height },
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
let _ = hwnd.InvalidateRect(None, false);
|
||||
}
|
||||
|
||||
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
|
||||
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
|
||||
/// both makes the guard idempotent and handles Windows already correcting some edges.
|
||||
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
|
||||
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, mb) = monitor;
|
||||
let (wl, wt, wr, wb) = work;
|
||||
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
|
||||
return None;
|
||||
}
|
||||
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
|
||||
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
|
||||
let border = [
|
||||
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
|
||||
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
|
||||
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
|
||||
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|n| (0..=FRAME).contains(n))
|
||||
.min()?;
|
||||
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
|
||||
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|
||||
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|
||||
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|
||||
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
fn borderless(style: co::WS) -> co::WS {
|
||||
style & !(co::WS::CAPTION | co::WS::BORDER)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{borderless, clamped_rect};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
fn the_borderless_style_drops_the_caption_and_border_only() {
|
||||
// `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000).
|
||||
let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX;
|
||||
let bare = borderless(decorated);
|
||||
assert!(!bare.has(co::WS::CAPTION), "caption gone");
|
||||
assert!(!bare.has(co::WS::BORDER), "border gone");
|
||||
assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept");
|
||||
assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept");
|
||||
// Already borderless: unchanged, so the per-frame call does nothing.
|
||||
assert_eq!(bare.raw(), borderless(bare).raw());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_respects_taskbars_on_every_edge() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (work, expected) in [
|
||||
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
|
||||
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
|
||||
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
|
||||
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
|
||||
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
|
||||
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
|
||||
] {
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
|
||||
let monitor = (-1920, -1080, 0, 0);
|
||||
let work = (-1920, -1032, 0, 0);
|
||||
for border in [0, 8, 16, 32] {
|
||||
let rect = (-1920 - border, -1080 - border, border, border);
|
||||
let expected = (-1920 - border, -1032 - border, border, border);
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 48, 1920, 1080);
|
||||
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//! `--control` setup failures must exit non-zero (#1816): a launcher that sees
|
||||
//! status 0 believes the control server is running when it is not.
|
||||
//!
|
||||
//! These tests run the real binary as a subprocess and assert the exit code,
|
||||
//! covering both failure paths in `main.rs`: control authentication and the
|
||||
//! automation workspace.
|
||||
|
||||
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
|
||||
/// workspace check instead of failing on token validation first.
|
||||
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Run `photocraft` with the given arguments in an isolated config dir and
|
||||
/// return the exit status and stderr. Each call gets its own config dir (tests run in
|
||||
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
|
||||
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
.args(args)
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
// Isolate from the host environment so the test is reproducible.
|
||||
.env_remove("PHOTOCRAFT_CONTROL_PORT")
|
||||
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
|
||||
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
|
||||
.env_remove("PHOTOCRAFT_AUTOMATION_READ_ROOT")
|
||||
.env_remove("PHOTOCRAFT_AUTOMATION_WRITE_ROOT")
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.output()
|
||||
.expect("failed to run photocraft binary");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_control_token_exits_with_status_2() {
|
||||
// `validate_token` rejects a token that is not exactly 64 hex characters.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
|
||||
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_token_and_token_file_exits_with_status_2() {
|
||||
// `server_token` rejects the combination of a supplied token and a token file.
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let token_file = dir.join("token.txt");
|
||||
std::fs::write(&token_file, VALID_TOKEN).unwrap();
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_automation_read_root_exits_with_status_2() {
|
||||
// `AuthorizedWorkspace::new` fails when the read root does not exist.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
|
||||
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_automation_write_root_exits_with_status_2() {
|
||||
// `AuthorizedWorkspace::new` fails when the write root does not exist.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
|
||||
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the
|
||||
//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the
|
||||
//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R
|
||||
//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and
|
||||
//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is
|
||||
//! ↩ inside the open menu.
|
||||
|
||||
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
|
||||
// unit tests compile here too without a harness to run them).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../src/mac_menu.rs"]
|
||||
#[allow(unused_imports, dead_code)]
|
||||
mod mac_menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu};
|
||||
use objc2_foundation::{NSPoint, NSString};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::native_menu;
|
||||
use serde_json::json;
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let titles = |menu: &NSMenu| -> Vec<String> {
|
||||
(0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect()
|
||||
};
|
||||
let submenu = |menu: &NSMenu, title: &str| -> Retained<NSMenu> {
|
||||
(0..menu.numberOfItems())
|
||||
.filter_map(|i| menu.itemAtIndex(i))
|
||||
.find(|it| it.title().to_string() == title)
|
||||
.and_then(|it| it.submenu())
|
||||
.unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu)))
|
||||
};
|
||||
let index = |menu: &NSMenu, title: &str| -> isize {
|
||||
(0..menu.numberOfItems())
|
||||
.find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title))
|
||||
.unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu)))
|
||||
};
|
||||
let main_menu = || nsapp.mainMenu().expect("a main menu");
|
||||
// Make `event` AppKit's current event, as the run loop does before dispatching it.
|
||||
let dequeue = |event: &NSEvent| {
|
||||
nsapp.postEvent_atStart(event, true);
|
||||
let mode = NSString::from_str("kCFRunLoopDefaultMode");
|
||||
let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event");
|
||||
assert_eq!(got.r#type(), event.r#type());
|
||||
got
|
||||
};
|
||||
let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| {
|
||||
let s = NSString::from_str(chars);
|
||||
NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode(
|
||||
NSEventType::KeyDown,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
flags,
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
&s,
|
||||
&s,
|
||||
false,
|
||||
code,
|
||||
)
|
||||
.expect("a key event")
|
||||
};
|
||||
let other = || {
|
||||
NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
|
||||
NSEventType::ApplicationDefined,
|
||||
NSPoint::new(0.0, 0.0),
|
||||
NSEventModifierFlags::empty(),
|
||||
0.0,
|
||||
0,
|
||||
None,
|
||||
NSEventSubtype::ApplicationActivated.0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.expect("an application-defined event")
|
||||
};
|
||||
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| {
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
native_menu::run(app, ui.ctx());
|
||||
photocraft_ui_egui::shortcuts::handle(app, ui.ctx());
|
||||
native_menu::sync(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
app.services.native_menu = mac_menu::install(&ctx, &app);
|
||||
assert!(app.services.native_menu.is_some(), "the menu bar installed");
|
||||
|
||||
// The Mac layout, as AppKit has it.
|
||||
let bar = main_menu();
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);
|
||||
let app_menu = submenu(&bar, "PhotoCraft");
|
||||
let names = titles(&app_menu);
|
||||
for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] {
|
||||
assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}");
|
||||
}
|
||||
let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit");
|
||||
assert_eq!(quit.keyEquivalent().to_string(), "q");
|
||||
assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command);
|
||||
assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit");
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu");
|
||||
|
||||
// Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild.
|
||||
let new_layer = |bar: &NSMenu| {
|
||||
let new = submenu(&submenu(bar, "Layer"), "New");
|
||||
new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled()
|
||||
};
|
||||
assert!(!new_layer(&bar), "no document: New Layer is disabled");
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).expect("a document");
|
||||
frame(&mut app, egui::RawInput::default());
|
||||
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it");
|
||||
assert!(new_layer(&main_menu()), "with a document New Layer is enabled");
|
||||
|
||||
// A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once.
|
||||
let rulers = app.ui.extras.rulers;
|
||||
let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command));
|
||||
assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent");
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
let keys: Vec<_> = raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
|
||||
.collect();
|
||||
// AppKit took only the key-down; winit delivers the key-up itself (#1638).
|
||||
assert_eq!(keys, [(egui::Key::R, true, true)], "the key press");
|
||||
frame(&mut app, raw);
|
||||
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once");
|
||||
|
||||
// A click: View › Rulers chosen with the mouse runs the command.
|
||||
let view = submenu(&main_menu(), "View");
|
||||
let at = index(&view, "Rulers");
|
||||
dequeue(&other());
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back");
|
||||
|
||||
// ↩ inside the open menu is a key-down without ⌘: still a click.
|
||||
dequeue(&key_down("\r", 36, NSEventModifierFlags::empty()));
|
||||
view.performActionForItemAtIndex(at);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events);
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
|
||||
|
||||
// Window ends with the open documents (#2991). Opening one rebuilds only the Window menu,
|
||||
// which AppKit still knows as its Window menu; choosing a row brings that document forward.
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).expect("a second document");
|
||||
frame(&mut app, egui::RawInput::default());
|
||||
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "a new document rebuilt only the Window menu");
|
||||
let window = submenu(&main_menu(), "Window");
|
||||
assert!(nsapp.windowsMenu().is_some_and(|w| Retained::as_ptr(&w) == Retained::as_ptr(&window)), "AppKit knows the new Window menu");
|
||||
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
|
||||
// The last rows (both documents are "Untitled").
|
||||
let rows = [window.numberOfItems() - 2, window.numberOfItems() - 1];
|
||||
let shown: Vec<String> = rows.iter().filter_map(|&i| window.itemAtIndex(i)).map(|it| it.title().to_string()).collect();
|
||||
assert_eq!(shown, names, "in tab order, after Bring All to Front: {:?}", titles(&window));
|
||||
let checked = |menu: &NSMenu, at: isize| menu.itemAtIndex(at).is_some_and(|it| it.state() == 1);
|
||||
assert!(!checked(&window, rows[0]) && checked(&window, rows[1]), "the active document is checked");
|
||||
dequeue(&other());
|
||||
window.performActionForItemAtIndex(rows[0]);
|
||||
let mut raw = egui::RawInput::default();
|
||||
if let Some(m) = app.services.native_menu.as_mut() {
|
||||
m.raw_input(&mut raw);
|
||||
}
|
||||
frame(&mut app, raw);
|
||||
assert_eq!(app.session.active_index(), Some(0), "choosing the first row brought it forward");
|
||||
let window = submenu(&main_menu(), "Window");
|
||||
assert!(checked(&window, rows[0]) && !checked(&window, rows[1]), "the check moved in place");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! A failed Linux desktop launch must not poison the next launch's graphics policy.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
#[test]
|
||||
fn missing_display_does_not_leave_a_gpu_crash_marker() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-no-display-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prefs = br#"{"performance":{"gpuBackend":"vulkan","useGpu":true}}"#;
|
||||
std::fs::write(dir.join("preferences.json"), prefs).unwrap();
|
||||
|
||||
let error = launch_without_display(&dir);
|
||||
assert!(!dir.join("gpu-starting.json").exists(), "a display error is not a GPU crash: {error}");
|
||||
assert_eq!(std::fs::read(dir.join("preferences.json")).unwrap(), prefs);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_display_preserves_existing_gpu_crash_evidence() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-no-display-prior-crash-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let marker = serde_json::json!({
|
||||
"backend": "vulkan", "adapter": "Test GPU", "adapterBackend": "vulkan",
|
||||
"driver": "test", "version": "test"
|
||||
});
|
||||
std::fs::write(dir.join("gpu-starting.json"), serde_json::to_vec(&marker).unwrap()).unwrap();
|
||||
launch_without_display(&dir);
|
||||
let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("gpu-starting.json")).unwrap()).unwrap();
|
||||
assert_eq!(restored, marker, "a windowing error must not erase or advance prior GPU crash evidence");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn launch_without_display(dir: &std::path::Path) -> String {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", dir)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.env_remove("PHOTOCRAFT_GPU_STARTUP_FAILURE")
|
||||
.env_remove("PHOTOCRAFT_CONTROL_PORT")
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "a desktop cannot open without a display");
|
||||
assert!(error.contains("WAYLAND_DISPLAY") && error.contains("DISPLAY"), "expected a display error, got: {error}");
|
||||
error.into_owned()
|
||||
}
|
||||
@@ -36,10 +36,7 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
|
||||
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
|
||||
Linux icon theme.
|
||||
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
|
||||
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
|
||||
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
|
||||
details and tests the `.ico` sizes and the WiX icon references.
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
|
||||
|
||||
## Regenerate
|
||||
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
assets/icons/lasso-magnetic.svg
|
||||
|
||||
Adapted from "Magnetic Lasso Tool" by Lil' Seal (failureiscool) from Noun Project,
|
||||
https://thenounproject.com/icon/magnetic-lasso-tool-177513/
|
||||
licensed under Creative Commons Attribution 3.0 Unported (CC BY 3.0),
|
||||
https://creativecommons.org/licenses/by/3.0/
|
||||
|
||||
Changes by PhotoCraft contributors: redrawn as a 24 x 24 SVG on PhotoCraft's toolbar grid, with
|
||||
round-capped strokes in place of the original line weights, so it can be tinted and rasterized
|
||||
like the other toolbar icons.
|
||||
|
||||
The full text of the licence follows.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Creative Commons Legal Code
|
||||
|
||||
Attribution 3.0 Unported
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
|
||||
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
|
||||
DAMAGES RESULTING FROM ITS USE.
|
||||
|
||||
License
|
||||
|
||||
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
|
||||
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
|
||||
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
|
||||
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
|
||||
|
||||
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
|
||||
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
|
||||
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
|
||||
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
|
||||
CONDITIONS.
|
||||
|
||||
1. Definitions
|
||||
|
||||
a. "Adaptation" means a work based upon the Work, or upon the Work and
|
||||
other pre-existing works, such as a translation, adaptation,
|
||||
derivative work, arrangement of music or other alterations of a
|
||||
literary or artistic work, or phonogram or performance and includes
|
||||
cinematographic adaptations or any other form in which the Work may be
|
||||
recast, transformed, or adapted including in any form recognizably
|
||||
derived from the original, except that a work that constitutes a
|
||||
Collection will not be considered an Adaptation for the purpose of
|
||||
this License. For the avoidance of doubt, where the Work is a musical
|
||||
work, performance or phonogram, the synchronization of the Work in
|
||||
timed-relation with a moving image ("synching") will be considered an
|
||||
Adaptation for the purpose of this License.
|
||||
b. "Collection" means a collection of literary or artistic works, such as
|
||||
encyclopedias and anthologies, or performances, phonograms or
|
||||
broadcasts, or other works or subject matter other than works listed
|
||||
in Section 1(f) below, which, by reason of the selection and
|
||||
arrangement of their contents, constitute intellectual creations, in
|
||||
which the Work is included in its entirety in unmodified form along
|
||||
with one or more other contributions, each constituting separate and
|
||||
independent works in themselves, which together are assembled into a
|
||||
collective whole. A work that constitutes a Collection will not be
|
||||
considered an Adaptation (as defined above) for the purposes of this
|
||||
License.
|
||||
c. "Distribute" means to make available to the public the original and
|
||||
copies of the Work or Adaptation, as appropriate, through sale or
|
||||
other transfer of ownership.
|
||||
d. "Licensor" means the individual, individuals, entity or entities that
|
||||
offer(s) the Work under the terms of this License.
|
||||
e. "Original Author" means, in the case of a literary or artistic work,
|
||||
the individual, individuals, entity or entities who created the Work
|
||||
or if no individual or entity can be identified, the publisher; and in
|
||||
addition (i) in the case of a performance the actors, singers,
|
||||
musicians, dancers, and other persons who act, sing, deliver, declaim,
|
||||
play in, interpret or otherwise perform literary or artistic works or
|
||||
expressions of folklore; (ii) in the case of a phonogram the producer
|
||||
being the person or legal entity who first fixes the sounds of a
|
||||
performance or other sounds; and, (iii) in the case of broadcasts, the
|
||||
organization that transmits the broadcast.
|
||||
f. "Work" means the literary and/or artistic work offered under the terms
|
||||
of this License including without limitation any production in the
|
||||
literary, scientific and artistic domain, whatever may be the mode or
|
||||
form of its expression including digital form, such as a book,
|
||||
pamphlet and other writing; a lecture, address, sermon or other work
|
||||
of the same nature; a dramatic or dramatico-musical work; a
|
||||
choreographic work or entertainment in dumb show; a musical
|
||||
composition with or without words; a cinematographic work to which are
|
||||
assimilated works expressed by a process analogous to cinematography;
|
||||
a work of drawing, painting, architecture, sculpture, engraving or
|
||||
lithography; a photographic work to which are assimilated works
|
||||
expressed by a process analogous to photography; a work of applied
|
||||
art; an illustration, map, plan, sketch or three-dimensional work
|
||||
relative to geography, topography, architecture or science; a
|
||||
performance; a broadcast; a phonogram; a compilation of data to the
|
||||
extent it is protected as a copyrightable work; or a work performed by
|
||||
a variety or circus performer to the extent it is not otherwise
|
||||
considered a literary or artistic work.
|
||||
g. "You" means an individual or entity exercising rights under this
|
||||
License who has not previously violated the terms of this License with
|
||||
respect to the Work, or who has received express permission from the
|
||||
Licensor to exercise rights under this License despite a previous
|
||||
violation.
|
||||
h. "Publicly Perform" means to perform public recitations of the Work and
|
||||
to communicate to the public those public recitations, by any means or
|
||||
process, including by wire or wireless means or public digital
|
||||
performances; to make available to the public Works in such a way that
|
||||
members of the public may access these Works from a place and at a
|
||||
place individually chosen by them; to perform the Work to the public
|
||||
by any means or process and the communication to the public of the
|
||||
performances of the Work, including by public digital performance; to
|
||||
broadcast and rebroadcast the Work by any means including signs,
|
||||
sounds or images.
|
||||
i. "Reproduce" means to make copies of the Work by any means including
|
||||
without limitation by sound or visual recordings and the right of
|
||||
fixation and reproducing fixations of the Work, including storage of a
|
||||
protected performance or phonogram in digital form or other electronic
|
||||
medium.
|
||||
|
||||
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
|
||||
limit, or restrict any uses free from copyright or rights arising from
|
||||
limitations or exceptions that are provided for in connection with the
|
||||
copyright protection under copyright law or other applicable laws.
|
||||
|
||||
3. License Grant. Subject to the terms and conditions of this License,
|
||||
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
perpetual (for the duration of the applicable copyright) license to
|
||||
exercise the rights in the Work as stated below:
|
||||
|
||||
a. to Reproduce the Work, to incorporate the Work into one or more
|
||||
Collections, and to Reproduce the Work as incorporated in the
|
||||
Collections;
|
||||
b. to create and Reproduce Adaptations provided that any such Adaptation,
|
||||
including any translation in any medium, takes reasonable steps to
|
||||
clearly label, demarcate or otherwise identify that changes were made
|
||||
to the original Work. For example, a translation could be marked "The
|
||||
original work was translated from English to Spanish," or a
|
||||
modification could indicate "The original work has been modified.";
|
||||
c. to Distribute and Publicly Perform the Work including as incorporated
|
||||
in Collections; and,
|
||||
d. to Distribute and Publicly Perform Adaptations.
|
||||
e. For the avoidance of doubt:
|
||||
|
||||
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme cannot be waived, the Licensor
|
||||
reserves the exclusive right to collect such royalties for any
|
||||
exercise by You of the rights granted under this License;
|
||||
ii. Waivable Compulsory License Schemes. In those jurisdictions in
|
||||
which the right to collect royalties through any statutory or
|
||||
compulsory licensing scheme can be waived, the Licensor waives the
|
||||
exclusive right to collect such royalties for any exercise by You
|
||||
of the rights granted under this License; and,
|
||||
iii. Voluntary License Schemes. The Licensor waives the right to
|
||||
collect royalties, whether individually or, in the event that the
|
||||
Licensor is a member of a collecting society that administers
|
||||
voluntary licensing schemes, via that society, from any exercise
|
||||
by You of the rights granted under this License.
|
||||
|
||||
The above rights may be exercised in all media and formats whether now
|
||||
known or hereafter devised. The above rights include the right to make
|
||||
such modifications as are technically necessary to exercise the rights in
|
||||
other media and formats. Subject to Section 8(f), all rights not expressly
|
||||
granted by Licensor are hereby reserved.
|
||||
|
||||
4. Restrictions. The license granted in Section 3 above is expressly made
|
||||
subject to and limited by the following restrictions:
|
||||
|
||||
a. You may Distribute or Publicly Perform the Work only under the terms
|
||||
of this License. You must include a copy of, or the Uniform Resource
|
||||
Identifier (URI) for, this License with every copy of the Work You
|
||||
Distribute or Publicly Perform. You may not offer or impose any terms
|
||||
on the Work that restrict the terms of this License or the ability of
|
||||
the recipient of the Work to exercise the rights granted to that
|
||||
recipient under the terms of the License. You may not sublicense the
|
||||
Work. You must keep intact all notices that refer to this License and
|
||||
to the disclaimer of warranties with every copy of the Work You
|
||||
Distribute or Publicly Perform. When You Distribute or Publicly
|
||||
Perform the Work, You may not impose any effective technological
|
||||
measures on the Work that restrict the ability of a recipient of the
|
||||
Work from You to exercise the rights granted to that recipient under
|
||||
the terms of the License. This Section 4(a) applies to the Work as
|
||||
incorporated in a Collection, but this does not require the Collection
|
||||
apart from the Work itself to be made subject to the terms of this
|
||||
License. If You create a Collection, upon notice from any Licensor You
|
||||
must, to the extent practicable, remove from the Collection any credit
|
||||
as required by Section 4(b), as requested. If You create an
|
||||
Adaptation, upon notice from any Licensor You must, to the extent
|
||||
practicable, remove from the Adaptation any credit as required by
|
||||
Section 4(b), as requested.
|
||||
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
|
||||
Collections, You must, unless a request has been made pursuant to
|
||||
Section 4(a), keep intact all copyright notices for the Work and
|
||||
provide, reasonable to the medium or means You are utilizing: (i) the
|
||||
name of the Original Author (or pseudonym, if applicable) if supplied,
|
||||
and/or if the Original Author and/or Licensor designate another party
|
||||
or parties (e.g., a sponsor institute, publishing entity, journal) for
|
||||
attribution ("Attribution Parties") in Licensor's copyright notice,
|
||||
terms of service or by other reasonable means, the name of such party
|
||||
or parties; (ii) the title of the Work if supplied; (iii) to the
|
||||
extent reasonably practicable, the URI, if any, that Licensor
|
||||
specifies to be associated with the Work, unless such URI does not
|
||||
refer to the copyright notice or licensing information for the Work;
|
||||
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
|
||||
a credit identifying the use of the Work in the Adaptation (e.g.,
|
||||
"French translation of the Work by Original Author," or "Screenplay
|
||||
based on original Work by Original Author"). The credit required by
|
||||
this Section 4 (b) may be implemented in any reasonable manner;
|
||||
provided, however, that in the case of a Adaptation or Collection, at
|
||||
a minimum such credit will appear, if a credit for all contributing
|
||||
authors of the Adaptation or Collection appears, then as part of these
|
||||
credits and in a manner at least as prominent as the credits for the
|
||||
other contributing authors. For the avoidance of doubt, You may only
|
||||
use the credit required by this Section for the purpose of attribution
|
||||
in the manner set out above and, by exercising Your rights under this
|
||||
License, You may not implicitly or explicitly assert or imply any
|
||||
connection with, sponsorship or endorsement by the Original Author,
|
||||
Licensor and/or Attribution Parties, as appropriate, of You or Your
|
||||
use of the Work, without the separate, express prior written
|
||||
permission of the Original Author, Licensor and/or Attribution
|
||||
Parties.
|
||||
c. Except as otherwise agreed in writing by the Licensor or as may be
|
||||
otherwise permitted by applicable law, if You Reproduce, Distribute or
|
||||
Publicly Perform the Work either by itself or as part of any
|
||||
Adaptations or Collections, You must not distort, mutilate, modify or
|
||||
take other derogatory action in relation to the Work which would be
|
||||
prejudicial to the Original Author's honor or reputation. Licensor
|
||||
agrees that in those jurisdictions (e.g. Japan), in which any exercise
|
||||
of the right granted in Section 3(b) of this License (the right to
|
||||
make Adaptations) would be deemed to be a distortion, mutilation,
|
||||
modification or other derogatory action prejudicial to the Original
|
||||
Author's honor and reputation, the Licensor will waive or not assert,
|
||||
as appropriate, this Section, to the fullest extent permitted by the
|
||||
applicable national law, to enable You to reasonably exercise Your
|
||||
right under Section 3(b) of this License (right to make Adaptations)
|
||||
but not otherwise.
|
||||
|
||||
5. Representations, Warranties and Disclaimer
|
||||
|
||||
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
|
||||
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
|
||||
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
|
||||
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
|
||||
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
|
||||
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
|
||||
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
|
||||
|
||||
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
|
||||
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
|
||||
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
|
||||
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
|
||||
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
7. Termination
|
||||
|
||||
a. This License and the rights granted hereunder will terminate
|
||||
automatically upon any breach by You of the terms of this License.
|
||||
Individuals or entities who have received Adaptations or Collections
|
||||
from You under this License, however, will not have their licenses
|
||||
terminated provided such individuals or entities remain in full
|
||||
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
|
||||
survive any termination of this License.
|
||||
b. Subject to the above terms and conditions, the license granted here is
|
||||
perpetual (for the duration of the applicable copyright in the Work).
|
||||
Notwithstanding the above, Licensor reserves the right to release the
|
||||
Work under different license terms or to stop distributing the Work at
|
||||
any time; provided, however that any such election will not serve to
|
||||
withdraw this License (or any other license that has been, or is
|
||||
required to be, granted under the terms of this License), and this
|
||||
License will continue in full force and effect unless terminated as
|
||||
stated above.
|
||||
|
||||
8. Miscellaneous
|
||||
|
||||
a. Each time You Distribute or Publicly Perform the Work or a Collection,
|
||||
the Licensor offers to the recipient a license to the Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
|
||||
offers to the recipient a license to the original Work on the same
|
||||
terms and conditions as the license granted to You under this License.
|
||||
c. If any provision of this License is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this License, and without further action
|
||||
by the parties to this agreement, such provision shall be reformed to
|
||||
the minimum extent necessary to make such provision valid and
|
||||
enforceable.
|
||||
d. No term or provision of this License shall be deemed waived and no
|
||||
breach consented to unless such waiver or consent shall be in writing
|
||||
and signed by the party to be charged with such waiver or consent.
|
||||
e. This License constitutes the entire agreement between the parties with
|
||||
respect to the Work licensed here. There are no understandings,
|
||||
agreements or representations with respect to the Work not specified
|
||||
here. Licensor shall not be bound by any additional provisions that
|
||||
may appear in any communication from You. This License may not be
|
||||
modified without the mutual written agreement of the Licensor and You.
|
||||
f. The rights granted under, and the subject matter referenced, in this
|
||||
License were drafted utilizing the terminology of the Berne Convention
|
||||
for the Protection of Literary and Artistic Works (as amended on
|
||||
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
|
||||
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
|
||||
and the Universal Copyright Convention (as revised on July 24, 1971).
|
||||
These rights and subject matter take effect in the relevant
|
||||
jurisdiction in which the License terms are sought to be enforced
|
||||
according to the corresponding provisions of the implementation of
|
||||
those treaty provisions in the applicable national law. If the
|
||||
standard suite of rights granted under applicable copyright law
|
||||
includes additional rights not granted under this License, such
|
||||
additional rights are deemed to be included in the License; this
|
||||
License is not intended to restrict the license of any rights under
|
||||
applicable law.
|
||||
|
||||
|
||||
Creative Commons Notice
|
||||
|
||||
Creative Commons is not a party to this License, and makes no warranty
|
||||
whatsoever in connection with the Work. Creative Commons will not be
|
||||
liable to You or any party on any legal theory for any damages
|
||||
whatsoever, including without limitation any general, special,
|
||||
incidental or consequential damages arising in connection to this
|
||||
license. Notwithstanding the foregoing two (2) sentences, if Creative
|
||||
Commons has expressly identified itself as the Licensor hereunder, it
|
||||
shall have all rights and obligations of Licensor.
|
||||
|
||||
Except for the limited purpose of indicating to the public that the
|
||||
Work is licensed under the CCPL, Creative Commons does not authorize
|
||||
the use by either party of the trademark "Creative Commons" or any
|
||||
related trademark or logo of Creative Commons without the prior
|
||||
written consent of Creative Commons. Any permitted use will be in
|
||||
compliance with Creative Commons' then-current trademark usage
|
||||
guidelines, as may be published on its website or otherwise made
|
||||
available upon request from time to time. For the avoidance of doubt,
|
||||
this trademark restriction does not form part of this License.
|
||||
|
||||
Creative Commons may be contacted at https://creativecommons.org/.
|
||||
@@ -1,207 +0,0 @@
|
||||
Original painting symmetry axis icons by @shorbank (2026).
|
||||
Files: symmetry-vertical.svg, symmetry-horizontal.svg, symmetry-dual.svg, symmetry-diagonal.svg.
|
||||
SPDX-License-Identifier: MIT OR Apache-2.0
|
||||
|
||||
These original icons are available under either licence below, at your option.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 shorbank
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 300 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Artboard tool glyph (not from an icon set): a board with a tick at each end of its edges. -->
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
<path d="M7 2v2.5M17 2v2.5M7 19.5V22M17 19.5V22" />
|
||||
<path d="M2 7h2.5M2 17h2.5M19.5 7H22M19.5 17H22" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 487 B |
@@ -1,21 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 12.01h.01" />
|
||||
<path d="M10 16.01h.01" />
|
||||
<path d="M14 12.01h.01" />
|
||||
<path d="M14 16.01h.01" />
|
||||
<path d="M18 8v12" />
|
||||
<path d="M6 8v12" />
|
||||
<rect x="2" y="8" width="20" height="12" rx="2" />
|
||||
<path d="M20 1v4" />
|
||||
<path d="M22 3h-4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 471 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 10 3 3" />
|
||||
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
|
||||
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
|
||||
<path d="M2.5 9A6 6 0 0 1 8 3" />
|
||||
<path d="m6 1.5 2 1.5-2 1.5" />
|
||||
<path d="M21.5 15a6 6 0 0 1-5.5 6" />
|
||||
<path d="m18 19.5-2 1.5 2 1.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 544 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
|
||||
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
|
||||
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
|
||||
<path d="M5 20.5h14" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 440 B |
@@ -1,18 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
|
||||
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
|
||||
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
|
||||
<path d="M5 20.5h14" />
|
||||
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
|
||||
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 591 B |
@@ -10,5 +10,5 @@
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
|
||||
<path d="M12 18a6 6 0 0 0 0-12v12z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 285 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Convert Point glyph: a corner point, an angle with a square anchor on its apex. -->
|
||||
<rect x="9.75" y="4.75" width="4.5" height="4.5" />
|
||||
<path d="M10.5 9.25 4 19" />
|
||||
<path d="M13.5 9.25 20 19" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 433 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
|
||||
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 410 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Frame tool glyph (not from an icon set): an empty placeholder frame, crossed corner to corner. -->
|
||||
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||
<path d="M6.5 7.5l11 9M17.5 7.5l-11 9" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 428 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 549 B |
@@ -1,17 +0,0 @@
|
||||
<!-- Adapted from "Magnetic Lasso Tool" by Lil' Seal from Noun Project (CC BY 3.0): see LICENSE-noun-magnetic-lasso.txt -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 1.9H16.4A4.6 4.6 0 0 1 21 6.5V7.7A4.6 4.6 0 0 1 16.4 12.3H9V8.95H16.55A0.6 0.6 0 0 0 17.15 8.35V5.85A0.6 0.6 0 0 0 16.55 5.25H9ZM9.75 2.65H12.5V4.5H9.75ZM9.75 9.7H12.5V11.55H9.75Z" fill="currentColor" fill-rule="evenodd" stroke="none" />
|
||||
<path d="M12.4 7.5 1.8 3.6l7.7 15.3-1.8 2.8" />
|
||||
<ellipse cx="9.25" cy="16.65" rx="2.85" ry="2.2" />
|
||||
<path d="m12.1 16.2 10.1-3-1.6-1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 729 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 316 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
||||
<line x1="8" x2="16" y1="12" y2="12" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,18 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Single Column Marquee glyph: a dashed column from edge to edge. -->
|
||||
<path d="M8 3h8" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 6.5v1" />
|
||||
<path d="M12 11.5v1" />
|
||||
<path d="M12 16.5v1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 422 B |
@@ -1,18 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Single Row Marquee glyph: a dashed row from edge to edge. -->
|
||||
<path d="M3 8v8" />
|
||||
<path d="M21 8v8" />
|
||||
<path d="M6.5 12h1" />
|
||||
<path d="M11.5 12h1" />
|
||||
<path d="M16.5 12h1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 416 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 346 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 29.33 30.81"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke-width="2.48" d="M1.33,11.42c0-4.05,3.28-7.33,7.33-7.33,2.08,0,4.06.88,5.45,2.43.28.3.75.32,1.06.04.01-.01.02-.02.04-.04,2.69-3.02,7.31-3.29,10.33-.6,1.57,1.4,2.46,3.4,2.45,5.5,0,3.05-2,5.33-4,7.33l-7.32,7.08c-.97,1.11-2.65,1.23-3.76.26-.08-.07-.16-.15-.24-.23l-7.34-7.11c-2-2-4-4.27-4-7.33" />
|
||||
<line stroke-width="2.41" x1="14.57" y1="1.3" x2="14.76" y2="29.51" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 577 B |
@@ -1,18 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
<path d="M18 3.5h5" />
|
||||
<path d="M20.5 1v5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 594 B |
@@ -1,21 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
<path d="M3 12C3 7 7 3 12 3" />
|
||||
<circle cx="3" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="3" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 765 B |
@@ -1,17 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
<path d="M18 3.5h5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 569 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
|
||||
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
|
||||
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
|
||||
<path d="m2.3 2.3 7.286 7.286" />
|
||||
<circle cx="11" cy="11" r="2" />
|
||||
</g>
|
||||
<path d="M8.5 8.5C6 6 7.5 3 5 3S2 6 4 7.5 5.5 11 3 12" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 683 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Perspective Crop tool glyph (not from an icon set): a crop frame seen in
|
||||
perspective, with its grid and two crop-mark ticks. -->
|
||||
<path d="M8 6h8l4 12H4z" />
|
||||
<path d="M12 6v12" />
|
||||
<path d="M6 12h12" />
|
||||
<path d="M8 2v2" />
|
||||
<path d="M20 22v-2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 493 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 10 3 3" />
|
||||
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
|
||||
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
|
||||
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="1.4" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -9,5 +9,6 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
</svg>
|
||||
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 596 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 588 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 30.81 30.81"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.41"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="25.445" y1="25.32" x2="5.365" y2="5.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 30.81 30.81"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.41"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="29.51" y1="15.31" x2="1.3" y2="15.5" />
|
||||
<line x1="15.5" y1="29.51" x2="15.31" y2="1.3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 321 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 30.81 30.81"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.41"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="29.51" y1="15.315" x2="1.3" y2="15.505" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 30.81 30.81"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.41"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="15.315" y1="1.3" x2="15.505" y2="29.51" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 272 B |
@@ -1,19 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Vertical Type Mask glyph (not from an icon set): a T drawn as marching
|
||||
ants beside a downward arrow (the text runs top to bottom). -->
|
||||
<path d="M2.5 7V4h12v3" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M8.5 4v16" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M6 20h5" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M20 8v12" />
|
||||
<path d="M17.5 17.5 20 20l2.5-2.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 596 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Horizontal Type Mask glyph (not from an icon set): a T drawn as marching ants. -->
|
||||
<path d="M4 7V4h16v3" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M12 4v16" stroke-dasharray="2.5 2.5" />
|
||||
<path d="M8.5 20h7" stroke-dasharray="2.5 2.5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 472 B |
@@ -21,8 +21,6 @@
|
||||
- [Overview](formats/overview.md)
|
||||
- [PSD and PSB](formats/psd-psb.md)
|
||||
- [Raster formats](formats/raster-formats.md)
|
||||
- [SVG](formats/svg.md)
|
||||
- [Affinity documents](formats/affinity.md)
|
||||
- [The `.pcraft` format](formats/pcraft.md)
|
||||
|
||||
# Automation
|
||||
|
||||
@@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi
|
||||
| Layer | Crates | Responsibility |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
|
||||
| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction |
|
||||
| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
|
||||
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
|
||||
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
|
||||
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
|
||||
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
|
||||
|
||||
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
|
||||
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
|
||||
|
||||
```sh
|
||||
photocraft-cli run input.psd \
|
||||
@@ -21,46 +21,6 @@ photocraft-cli run input.psd \
|
||||
--out output.png
|
||||
```
|
||||
|
||||
### Missing font warnings
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
|
||||
If a type layer uses a font family that is not installed, `convert` and `run`
|
||||
write a warning to standard error before exporting; they do not silently present
|
||||
the fallback rendering as an exact match. `info --compact` includes the same
|
||||
warning in its `warnings` JSON array, without writing it to stderr. `run`
|
||||
also adds `warnings` to the JSON result for the command that first exposes
|
||||
the missing family. A multi-command `run` reports each missing family once,
|
||||
even if later commands still use it.
|
||||
|
||||
Warnings do not change the saved document's font declarations and do not stop
|
||||
the operation. To list or replace the missing families intentionally, use
|
||||
`type.resolveMissingFonts` on the document.
|
||||
|
||||
### Export selected adjustment layers as a .cube LUT
|
||||
|
||||
The engine command `file.export.colorLookupTables` creates a 3D LUT from an
|
||||
identity RGB lattice. It can export all visible top-level adjustment layers
|
||||
(the default), the current Layers panel selection, or a supplied list of
|
||||
adjustment layer IDs. The `scope` and `layers` parameters are mutually
|
||||
exclusive when `scope` is `selected`:
|
||||
|
||||
```json
|
||||
{"size": 33, "scope": "selected", "path": "chosen-look.cube"}
|
||||
```
|
||||
|
||||
For automation that does not use the Layers panel, supply `"layers": [10, 14]`
|
||||
instead. Input ID order does not change the final color look: adjustments are
|
||||
always baked in **document stacking order**, bottom to top. When `path` is
|
||||
omitted, the command returns the .cube text in `cube`; both forms return
|
||||
`size` and `layerCount`.
|
||||
|
||||
Valid LUT dimensions are 2–129 (same maximum as PhotoCraft's LUT reader).
|
||||
Selected IDs must exist, be visible top-level adjustment layers, and contain
|
||||
no duplicates; invalid selections report an error rather than silently
|
||||
exporting a different look. Masks, vector masks and clipping are spatial
|
||||
operations and cannot be represented faithfully by a color-only LUT, so the
|
||||
baker omits them. Group-contained adjustments are not exported as independent
|
||||
layers because that would lose their group's blending semantics.
|
||||
|
||||
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
|
||||
|
||||
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
|
||||
@@ -19,23 +19,6 @@ photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/co
|
||||
|
||||
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
|
||||
|
||||
## From an installed release
|
||||
|
||||
The release packages ship `photocraft-cli` alongside the desktop app, so no build is needed:
|
||||
|
||||
| Install | CLI |
|
||||
|---|---|
|
||||
| Windows (MSI) | `C:\Program Files\PhotoCraft\photocraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
|
||||
| Linux (deb, rpm) | `/usr/bin/photocraft-cli` |
|
||||
| macOS | the separate `photocraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
|
||||
|
||||
```sh
|
||||
# Windows, default install folder
|
||||
claude mcp add photocraft -- "C:\Program Files\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
|
||||
# Linux, or macOS with the CLI unzipped onto PATH
|
||||
claude mcp add photocraft -- photocraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
Stdio MCP does not open a network listener. Its client receives only the read and write roots
|
||||
@@ -47,9 +30,3 @@ The current server does not issue scoped sessions or distinguish trusted tools f
|
||||
|
||||
Filesystem capabilities are implemented. General tool and method capabilities remain
|
||||
[proposed](../security/automation-security.md).
|
||||
|
||||
The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect`
|
||||
and `render_preview`. Existing documented tools, including `doc_render_preview`, remain
|
||||
listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless
|
||||
Render Video supports progress and cancellation using the granted write root; see
|
||||
[`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior.
|
||||