Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237a4707eb |
No files matched your search
@@ -1,24 +0,0 @@
|
||||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
|
||||
# keeps working (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
#
|
||||
# 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'
|
||||
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
|
||||
# at the end of the log. The default profile's GPU override still applies.
|
||||
[profile.ci]
|
||||
fail-fast = true
|
||||
slow-timeout = { period = "60s", terminate-after = 10 }
|
||||
failure-output = "immediate-final"
|
||||
@@ -17,10 +17,6 @@ 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).
|
||||
@@ -39,12 +35,10 @@ jobs:
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
# macOS is tested only by release.yml.
|
||||
platforms:
|
||||
timeout-minutes: 10
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -60,17 +54,8 @@ jobs:
|
||||
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(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
@@ -78,7 +63,6 @@ jobs:
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -113,52 +97,25 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
|
||||
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -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
|
||||
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
- name: Test
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
|
||||
- name: Doctests
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
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 nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
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).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -172,12 +129,21 @@ jobs:
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -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:
|
||||
timeout-minutes: 30
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
@@ -210,7 +176,6 @@ jobs:
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -226,56 +191,7 @@ jobs:
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
name: web build and wasm size gate
|
||||
# PRs run it only when web-relevant files change (see `platforms`); main always does.
|
||||
needs: platforms
|
||||
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 builds, validates relative URLs, cuts the wasm into
|
||||
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
|
||||
- name: Build and verify browser release
|
||||
run: packaging/web/package.sh
|
||||
- name: Report wasm size
|
||||
if: success()
|
||||
run: |
|
||||
{
|
||||
echo "### Browser WebAssembly size"
|
||||
echo
|
||||
echo "| File | Bytes | MiB | Limit |"
|
||||
echo "| --- | ---: | ---: | ---: |"
|
||||
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
|
||||
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
|
||||
for wasm in dist/web/*.wasm; do
|
||||
[ -f "$wasm" ] || continue
|
||||
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
|
||||
done
|
||||
# What a host sees: the largest file of the packaged site.
|
||||
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
|
||||
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fuzz:
|
||||
timeout-minutes: 60
|
||||
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
||||
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
|
||||
@@ -287,14 +203,6 @@ jobs:
|
||||
- { 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 }
|
||||
|
||||
@@ -27,7 +27,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -19,13 +19,47 @@ 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:
|
||||
timeout-minutes: 10
|
||||
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 packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/photocraft.wxs packaging/windows/photocraft.en-us.wxl 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,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,407 @@ 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 }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (today the Japanese fonts). An optional build input for everyone else; see
|
||||
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
|
||||
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### PhotoCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo 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 }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/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
|
||||
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
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
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
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"
|
||||
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
|
||||
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
|
||||
- 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, freebsd, 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"
|
||||
@@ -22,10 +22,72 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
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
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: photocraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: photocraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
|
||||
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'photocraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok photocraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -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, 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, 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,12 +11,8 @@ 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/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 |
|
||||
| `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 |
|
||||
| `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` |
|
||||
@@ -39,7 +35,6 @@ apps/
|
||||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
@@ -75,16 +70,15 @@ 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`).
|
||||
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. **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`.
|
||||
3. `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`.
|
||||
4. `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).
|
||||
@@ -96,7 +90,7 @@ 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
|
||||
@@ -105,8 +99,6 @@ cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --upd
|
||||
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).
|
||||
@@ -125,13 +117,13 @@ 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/parity.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `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
|
||||
|
||||
|
||||
@@ -10,53 +10,25 @@ 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`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 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)
|
||||
|
||||
@@ -112,8 +84,7 @@ next to the files:
|
||||
| `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/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [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) | CC0-1.0 (vector-art); 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.
|
||||
@@ -4,13 +4,12 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
[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" }
|
||||
@@ -38,7 +37,6 @@ 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 +44,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,10 +66,6 @@ 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"
|
||||
@@ -84,14 +73,14 @@ 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.
|
||||
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
|
||||
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. 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. Issue #198:
|
||||
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
@@ -99,35 +88,6 @@ 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]
|
||||
@@ -140,4 +100,3 @@ opt-level = 3
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
@@ -133,7 +133,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 +178,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>🧰 52 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -187,7 +187,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 +199,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, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), 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 AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format. 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.
|
||||
</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>
|
||||
@@ -277,7 +277,7 @@ 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 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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -298,7 +298,7 @@ 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
|
||||
|
||||
@@ -350,8 +350,6 @@ Installers and executables are code-signed.
|
||||
| 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 |
|
||||
|
||||
@@ -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` |
|
||||
@@ -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,20 @@ 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>] [--tiff-layers]
|
||||
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>] [--quality <1-100>] [--tiff-layers]
|
||||
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,
|
||||
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
Apply an action list to every image in a directory. 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.
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An --out folder that is the
|
||||
--in folder is refused, as the results would replace the originals; --in-place allows it.
|
||||
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>]
|
||||
@@ -47,7 +39,7 @@ USAGE:
|
||||
[--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
|
||||
doc.open/new/save/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
|
||||
@@ -69,20 +61,10 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality"], 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: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
|
||||
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], 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 {
|
||||
@@ -212,13 +194,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
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"))?);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -246,7 +221,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
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));
|
||||
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
@@ -298,16 +273,11 @@ 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}"))?;
|
||||
@@ -319,11 +289,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
if cmds.is_empty() && a.get("--out").is_none() {
|
||||
return Err("run needs at least one --cmd (or --out)".into());
|
||||
}
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
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()
|
||||
@@ -352,7 +320,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
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 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(())
|
||||
@@ -363,71 +330,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
|
||||
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 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))
|
||||
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
@@ -438,7 +341,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
|| 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,10 +351,8 @@ 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).
|
||||
@@ -477,19 +378,9 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
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())
|
||||
@@ -541,7 +432,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);
|
||||
@@ -655,31 +548,6 @@ mod missing_font_warning_tests {
|
||||
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()));
|
||||
@@ -701,54 +569,3 @@ mod missing_font_warning_tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,6 @@ fn unknown_flags_are_usage_errors() {
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||||
for needle in ["ellipse", "marquee"] {
|
||||
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
|
||||
assert!(out.contains("select.rect"), "{needle}: {out}");
|
||||
}
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
@@ -141,8 +136,7 @@ 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);
|
||||
@@ -194,11 +188,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 +200,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();
|
||||
@@ -272,24 +220,6 @@ fn run_errors() {
|
||||
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]
|
||||
fn batch_applies_actions_to_directory() {
|
||||
let d = tmp("batch");
|
||||
@@ -315,37 +245,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();
|
||||
@@ -460,66 +359,6 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
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]
|
||||
@@ -723,33 +562,3 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
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());
|
||||
}
|
||||
@@ -125,19 +125,14 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
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.
|
||||
// Everything but the file's own path 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));
|
||||
let (ia, 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));
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[package]
|
||||
name = "photocraft-web-boot"
|
||||
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "photocraft-web-boot"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Inflates the parts in browsers without `DecompressionStream`.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web-boot"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
@@ -1,30 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" 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; } }
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
|
||||
//!
|
||||
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
|
||||
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
|
||||
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
|
||||
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
|
||||
//! loader, so the browser still compiles while downloading. No file on the host is larger than
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
//!
|
||||
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
|
||||
//! for hosts without a per-file cap; only the packaged site starts through this module.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
|
||||
/// without this an HTML page would be read as a list of parts.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const MARKER: &str = "photocraft-parts 1";
|
||||
|
||||
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
|
||||
/// file next to `parts.txt`: anything that could point elsewhere is refused.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn parts(list: &str) -> Option<Vec<String>> {
|
||||
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
if lines.next() != Some(MARKER) {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<String> = lines.map(str::to_owned).collect();
|
||||
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
impl Inflater {
|
||||
fn new() -> Self {
|
||||
Inflater(flate2::write::GzDecoder::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
|
||||
/// to input until it has a whole block).
|
||||
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
std::io::Write::write_all(&mut self.0, chunk)?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
|
||||
/// The rest, once the stream has ended. An error when it ended early or its checksum is
|
||||
/// wrong, the same as the browser's inflater gives.
|
||||
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
|
||||
self.0.try_finish()?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod boot {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
|
||||
TransformStreamDefaultController, Transformer, WritableStream,
|
||||
};
|
||||
|
||||
use super::Inflater;
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
/// Seconds to wait before each further try; one more than this many tries are made.
|
||||
const RETRY_AFTER: [u32; 2] = [1, 3];
|
||||
|
||||
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
|
||||
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
|
||||
// name (Trunk's own name for it carries a content hash).
|
||||
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = default, catch)]
|
||||
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
|
||||
}
|
||||
|
||||
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
|
||||
// have to be set for the whole build, so the three members used are declared here.
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
type DecompressionStream;
|
||||
#[wasm_bindgen(constructor, catch)]
|
||||
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn readable(this: &DecompressionStream) -> ReadableStream;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn writable(this: &DecompressionStream) -> WritableStream;
|
||||
}
|
||||
|
||||
fn window() -> Result<web_sys::Window, JsValue> {
|
||||
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
|
||||
}
|
||||
|
||||
/// `fresh` skips the browser's cache.
|
||||
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
|
||||
let init = RequestInit::new();
|
||||
if fresh {
|
||||
init.set_cache(RequestCache::Reload);
|
||||
}
|
||||
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
|
||||
if !response.ok() {
|
||||
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
|
||||
}
|
||||
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
|
||||
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Copies each part into `sink` in order, then closes it.
|
||||
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
|
||||
let keep_open = StreamPipeOptions::new();
|
||||
keep_open.set_prevent_close(true);
|
||||
for part in &parts {
|
||||
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
|
||||
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
|
||||
}
|
||||
JsFuture::from(sink.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Passes inflated bytes on to the stream's reader.
|
||||
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
|
||||
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
|
||||
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
|
||||
}
|
||||
|
||||
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
|
||||
fn own_inflater() -> Result<TransformStream, JsValue> {
|
||||
let inflater = Rc::new(RefCell::new(Inflater::new()));
|
||||
let last = inflater.clone();
|
||||
let busy = || JsValue::from_str("the inflater is in use");
|
||||
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
|
||||
move |chunk: JsValue, out: TransformStreamDefaultController| {
|
||||
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
|
||||
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
|
||||
},
|
||||
);
|
||||
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
|
||||
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
|
||||
});
|
||||
let transformer = Transformer::new();
|
||||
transformer.set_transform(&transform.into_js_value().unchecked_into());
|
||||
transformer.set_flush(&flush.into_js_value().unchecked_into());
|
||||
TransformStream::new_with_transformer(&transformer)
|
||||
}
|
||||
|
||||
/// `compressed` inflated: by the browser where it can, otherwise here.
|
||||
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
|
||||
let inflater = DecompressionStream::new("gzip")?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
} else {
|
||||
let inflater = own_inflater()?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
};
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
let wasm = inflated(&joined.readable())?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
let sink = joined.writable();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = feed(parts, sink.clone(), fresh).await {
|
||||
let _ = sink.abort_with_reason(&e);
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
init.set_headers(&headers);
|
||||
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
|
||||
let arg = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
|
||||
match start_app(arg.into()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(cause.take().unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(seconds: u32) {
|
||||
let wait = js_sys::Promise::new(&mut |done, _| {
|
||||
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
|
||||
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
|
||||
let _ = done.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(wait).await;
|
||||
}
|
||||
|
||||
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
|
||||
fn say(text: &str, reload: bool) {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
|
||||
loading.set_text_content(None);
|
||||
let Ok(line) = document.create_element("p") else { return };
|
||||
line.set_text_content(Some(text));
|
||||
if reload && let Ok(link) = document.create_element("a") {
|
||||
link.set_text_content(Some("Reload"));
|
||||
let _ = link.set_attribute("href", "");
|
||||
let _ = line.append_child(&link);
|
||||
}
|
||||
let _ = loading.append_child(&line);
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
spawn_local(async {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let error = match run(attempt > 0).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => e,
|
||||
};
|
||||
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
|
||||
let Some(&wait) = RETRY_AFTER.get(attempt) else {
|
||||
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
|
||||
return;
|
||||
};
|
||||
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
boot::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Inflater, MARKER, parts};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, bytes).expect("gzip");
|
||||
z.finish().expect("gzip")
|
||||
}
|
||||
|
||||
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
|
||||
fn sample() -> Vec<u8> {
|
||||
let mut x = 0x2545_f491_4f6c_dd1d_u64;
|
||||
(0..200_000)
|
||||
.map(|i| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
|
||||
/// they are cut.
|
||||
#[test]
|
||||
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
|
||||
let (plain, packed) = (sample(), gzip(&sample()));
|
||||
for cut in [1, 7, 4096, 65_536, packed.len()] {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = Vec::new();
|
||||
for chunk in packed.chunks(cut) {
|
||||
out.extend(inflater.push(chunk).expect("push"));
|
||||
}
|
||||
out.extend(inflater.finish().expect("finish"));
|
||||
assert_eq!(out, plain, "chunks of {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
|
||||
/// then tries again), never start a damaged app.
|
||||
#[test]
|
||||
fn the_inflater_refuses_a_short_or_damaged_stream() {
|
||||
let packed = gzip(&sample());
|
||||
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = inflater.push(bytes)?;
|
||||
out.extend(inflater.finish()?);
|
||||
Ok(out)
|
||||
};
|
||||
assert!(run(&packed).is_ok());
|
||||
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
|
||||
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
|
||||
let mut damaged = packed.clone();
|
||||
let middle = damaged.len() / 2;
|
||||
damaged[middle] ^= 0x55;
|
||||
assert!(run(&damaged).is_err(), "a changed byte");
|
||||
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
|
||||
}
|
||||
|
||||
/// A host that answers a missing file with its home page must not have that page read as a
|
||||
/// list of parts.
|
||||
#[test]
|
||||
fn a_web_page_is_not_a_parts_list() {
|
||||
assert_eq!(parts(include_str!("../index.html")), None);
|
||||
assert_eq!(parts(""), None);
|
||||
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
|
||||
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
|
||||
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
|
||||
}
|
||||
|
||||
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
|
||||
/// origin, refuses the whole list.
|
||||
#[test]
|
||||
fn a_part_name_cannot_point_outside_the_app_folder() {
|
||||
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
|
||||
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The packaged site shows this crate's page, the development build the app's own. They are
|
||||
/// the same page apart from which program the `<link data-trunk>` line builds.
|
||||
#[test]
|
||||
fn the_page_is_the_apps_page() {
|
||||
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
|
||||
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
|
||||
assert_eq!(trunk(boot), trunk(app));
|
||||
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
|
||||
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,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", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException"] }
|
||||
@@ -4,7 +4,7 @@
|
||||
<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-cargo-profile="wasm-release" data-cargo-features="heif" 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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,26 @@
|
||||
//! 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 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
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "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", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
|
||||
"afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
/// 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 +41,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 +49,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()));
|
||||
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()
|
||||
@@ -97,7 +60,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -162,62 +125,6 @@ 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 {
|
||||
@@ -225,61 +132,6 @@ struct WebShell {
|
||||
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,79 +151,42 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.serve_fonts(ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.save_presets(ctx);
|
||||
self.unsaved.store(self.app.has_unsaved_work(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
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 {
|
||||
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())
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
photocraft_io::export(doc, path, &opts).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,
|
||||
}
|
||||
// The browser's file picker hands over the file's contents, not a path.
|
||||
FileDialogRequest::Open { .. } => wasm_bindgen_futures::spawn_local(async move {
|
||||
let picked = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await;
|
||||
let answer = match picked {
|
||||
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());
|
||||
@@ -432,8 +247,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ 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", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -43,8 +43,7 @@ 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"] }
|
||||
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] }
|
||||
# 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.
|
||||
@@ -64,16 +63,9 @@ 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 }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] }
|
||||
# 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"
|
||||
@@ -81,9 +73,8 @@ async-io = "=2.6.0"
|
||||
# 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.
|
||||
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), 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"
|
||||
|
||||
@@ -26,25 +26,6 @@ pub fn window_icon() -> egui::IconData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
|
||||
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
|
||||
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
|
||||
/// replaced it with a flat picture (#5849). `exe` is the running executable.
|
||||
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
|
||||
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
|
||||
}
|
||||
|
||||
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
|
||||
fn in_app_bundle(exe: &std::path::Path) -> bool {
|
||||
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
let macos = exe.parent();
|
||||
let contents = macos.and_then(std::path::Path::parent);
|
||||
let bundle = contents.and_then(std::path::Path::parent);
|
||||
name(macos).as_deref() == Some("MacOS")
|
||||
&& name(contents).as_deref() == Some("Contents")
|
||||
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -64,22 +45,6 @@ mod tests {
|
||||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
|
||||
/// Linux and Windows builds) still set the runtime icon.
|
||||
#[test]
|
||||
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
|
||||
use std::path::Path;
|
||||
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
|
||||
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
|
||||
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
|
||||
assert!(!in_app_bundle(Path::new("photocraft")));
|
||||
assert!(sets_runtime_icon(None));
|
||||
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
|
||||
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,9 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
AUTH_DEADLINE, AuthDeadlineStream, 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> {
|
||||
@@ -48,17 +46,11 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
|
||||
}
|
||||
|
||||
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
|
||||
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
|
||||
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
|
||||
if configure_stream(&stream).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
@@ -81,35 +73,24 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
if !authenticated {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if ok && reader.get_mut().authenticated().is_err() {
|
||||
break;
|
||||
}
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
|
||||
break;
|
||||
}
|
||||
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 deadline = Instant::now() + Duration::from_secs(60);
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
let dispatch = req.dispatch.clone();
|
||||
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(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -131,34 +112,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let started = Instant::now();
|
||||
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
|
||||
started.elapsed()
|
||||
});
|
||||
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
|
||||
// the idle timeout.
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
for i in 0..150 {
|
||||
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
|
||||
if stream.write_all(byte).is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let served = server.join().unwrap();
|
||||
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
|
||||
assert!(rx.try_recv().is_err(), "nothing was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
use photocraft_automation::budgets::MAX_RESPONSE_BYTES;
|
||||
@@ -246,44 +199,4 @@ mod tests {
|
||||
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,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();
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@
|
||||
//! 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.
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
@@ -177,12 +175,6 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
|
||||
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() {
|
||||
@@ -197,9 +189,7 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
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,
|
||||
Os::Other => wgpu::Backends::GL,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -232,11 +222,8 @@ pub struct Candidate {
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
|
||||
/// for presenting the window (see [`backends`]). 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));
|
||||
/// Intel on Windows.
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
@@ -256,7 +243,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
wgpu::Backend::Gl => 3,
|
||||
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
|
||||
};
|
||||
(fallback, ty, be)
|
||||
(ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
@@ -274,14 +261,6 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
|
||||
&& 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).
|
||||
@@ -290,9 +269,6 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
|
||||
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();
|
||||
@@ -548,33 +524,12 @@ mod tests {
|
||||
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::Other), Some(wgpu::Backends::GL));
|
||||
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
|
||||
}
|
||||
|
||||
@@ -614,42 +569,6 @@ mod tests {
|
||||
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);
|
||||
@@ -714,7 +633,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (prev, _s) = begin_when_unlocked(&dir);
|
||||
let (prev, _s) = Sentinel::begin(&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);
|
||||
@@ -726,22 +645,6 @@ mod tests {
|
||||
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");
|
||||
@@ -757,7 +660,16 @@ mod tests {
|
||||
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);
|
||||
// Another test in this binary spawns a process: between its fork and exec the child briefly
|
||||
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
|
||||
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);
|
||||
}
|
||||
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.
|
||||
@@ -770,29 +682,6 @@ mod tests {
|
||||
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 = [
|
||||
@@ -810,7 +699,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (previous, s) = begin_when_unlocked(&dir);
|
||||
let (previous, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
|
||||
if !keep {
|
||||
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
|
||||
@@ -880,28 +769,4 @@ mod tests {
|
||||
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")]);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
|
||||
//! warn and start anyway. `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.
|
||||
@@ -111,10 +111,9 @@ pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache".
|
||||
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()))
|
||||
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
|
||||
}
|
||||
|
||||
/// Where we look for libraries, and what we found there.
|
||||
@@ -201,7 +200,7 @@ fn install_command(packages: &[&str], distro: Distro) -> String {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
|
||||
let session_name = match session {
|
||||
DisplaySession::Wayland => "Wayland",
|
||||
DisplaySession::X11 => "X11",
|
||||
@@ -215,26 +214,26 @@ pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distr
|
||||
packages.push(p);
|
||||
}
|
||||
}
|
||||
let mut msg = format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "));
|
||||
let mut msg = if conclusive {
|
||||
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
|
||||
} else {
|
||||
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\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");
|
||||
if conclusive {
|
||||
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"
|
||||
};
|
||||
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-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));
|
||||
}
|
||||
@@ -246,9 +245,7 @@ fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
/// `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()
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
@@ -265,12 +262,6 @@ 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 {
|
||||
@@ -278,7 +269,8 @@ pub fn available(session: DisplaySession) -> bool {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// the message to print before exiting with a non-zero status; a warning is printed here and the
|
||||
/// app starts.
|
||||
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(());
|
||||
@@ -286,17 +278,18 @@ pub fn preflight(session: DisplaySession) -> Result<(), String> {
|
||||
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(()),
|
||||
match check(session, &system_probe(), |p| p.exists()) {
|
||||
Verdict::Ok => Ok(()),
|
||||
Verdict::Missing { missing, conclusive } => {
|
||||
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
|
||||
let msg = missing_message(session, &missing, distro, conclusive);
|
||||
if conclusive {
|
||||
Err(msg)
|
||||
} else {
|
||||
eprint!("{msg}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,10 +327,6 @@ mod tests {
|
||||
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")));
|
||||
@@ -373,7 +362,7 @@ mod tests {
|
||||
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);
|
||||
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
|
||||
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}");
|
||||
@@ -395,13 +384,13 @@ mod tests {
|
||||
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);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
|
||||
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() {
|
||||
fn library_dirs_count_and_unread_cache_only_warns() {
|
||||
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"));
|
||||
@@ -409,29 +398,16 @@ mod tests {
|
||||
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).
|
||||
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
|
||||
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);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
|
||||
assert!(msg.contains("Starting anyway"), "{msg}");
|
||||
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);
|
||||
|
||||
@@ -5,15 +5,13 @@
|
||||
//! - 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.
|
||||
//! else (labels, enabled, checked) is updated in place.
|
||||
//! - 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::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
@@ -51,8 +49,6 @@ pub struct MacMenu {
|
||||
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 {
|
||||
@@ -71,7 +67,7 @@ impl MacMenu {
|
||||
// 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 }
|
||||
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
|
||||
}
|
||||
|
||||
fn build(&mut self, bar: &MenuBar) {
|
||||
@@ -79,15 +75,12 @@ impl MacMenu {
|
||||
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 sub = Submenu::new(escape(&m.title), true);
|
||||
self.append(&sub, &m.children);
|
||||
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));
|
||||
}
|
||||
@@ -104,33 +97,6 @@ impl MacMenu {
|
||||
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 {
|
||||
@@ -194,9 +160,6 @@ impl Backend for MacMenu {
|
||||
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 };
|
||||
|
||||
@@ -22,18 +22,12 @@
|
||||
|
||||
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")]
|
||||
@@ -43,14 +37,12 @@ mod mac_window;
|
||||
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;
|
||||
@@ -75,14 +67,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
/// 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.
|
||||
/// different offset every launch (#419). Wayland compositors place windows themselves.
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
let viewport = egui::ViewportBuilder::default();
|
||||
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
|
||||
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
|
||||
eframe::NativeOptions {
|
||||
viewport: viewport
|
||||
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])
|
||||
@@ -96,35 +85,10 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
// 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.
|
||||
@@ -171,8 +135,6 @@ 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()) {
|
||||
@@ -246,8 +208,6 @@ fn main() -> eframe::Result {
|
||||
// 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
|
||||
@@ -278,7 +238,7 @@ fn main() -> eframe::Result {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
eprintln!("photocraft: cannot configure control authentication: {e}");
|
||||
std::process::exit(2);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Some(path) = token_file {
|
||||
@@ -292,7 +252,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))
|
||||
@@ -315,9 +275,6 @@ fn main() -> eframe::Result {
|
||||
|
||||
// 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"))]
|
||||
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);
|
||||
@@ -333,9 +290,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// 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();
|
||||
@@ -384,7 +338,6 @@ fn main() -> eframe::Result {
|
||||
#[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 =
|
||||
@@ -403,14 +356,7 @@ fn main() -> eframe::Result {
|
||||
// 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));
|
||||
}
|
||||
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),
|
||||
@@ -455,13 +401,24 @@ fn main() -> eframe::Result {
|
||||
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);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember_cpu =
|
||||
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if remember_cpu
|
||||
&& let Err(error) = app.run(
|
||||
"prefs.set",
|
||||
serde_json::json!({"values": {
|
||||
"performance.renderingMode": "cpu", "performance.useGpu": false
|
||||
}}),
|
||||
)
|
||||
{
|
||||
log::warn!("couldn't remember CPU recovery: {error}");
|
||||
}
|
||||
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()}))
|
||||
@@ -487,16 +444,6 @@ fn main() -> eframe::Result {
|
||||
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:
|
||||
@@ -614,44 +561,6 @@ mod tests {
|
||||
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
|
||||
@@ -672,13 +581,4 @@ mod tests {
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,7 @@
|
||||
//! 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).
|
||||
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
@@ -21,33 +16,15 @@ use photocraft_engine::display_color::Display;
|
||||
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))
|
||||
if !cfg!(target_os = "macos") {
|
||||
return None;
|
||||
}
|
||||
#[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> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(detect());
|
||||
});
|
||||
rx
|
||||
Some(rx)
|
||||
}
|
||||
|
||||
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
|
||||
@@ -133,225 +110,9 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
|
||||
Ok(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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());
|
||||
}
|
||||
}
|
||||
Err("this platform doesn't report display profiles".into())
|
||||
}
|
||||
|
||||
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
|
||||
@@ -524,7 +285,6 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -48,66 +48,6 @@ 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);
|
||||
@@ -115,7 +55,10 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
image::{Image, PixelLayout},
|
||||
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{AtomEnum, ConnectionExt, 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")?;
|
||||
@@ -128,16 +71,13 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
{
|
||||
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()
|
||||
};
|
||||
let regions: Vec<(i16, i16, u16, u16, bool)> =
|
||||
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).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 {
|
||||
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());
|
||||
}
|
||||
@@ -152,20 +92,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
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 atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
|
||||
let primary_profile = if atom == 0 {
|
||||
None
|
||||
} else {
|
||||
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
|
||||
};
|
||||
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 {
|
||||
for (x, y, mw, mh, primary) 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 {
|
||||
@@ -180,7 +120,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
|
||||
width: usize::from(mw),
|
||||
height: usize::from(mh),
|
||||
rgba,
|
||||
profile,
|
||||
profile: if primary { primary_profile.clone() } else { None },
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
@@ -419,18 +359,6 @@ mod tests {
|
||||
}
|
||||
#[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::{
|
||||
|
||||
@@ -5,7 +5,7 @@ use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recovered, Services};
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,18 +13,13 @@ use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
|
||||
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
|
||||
"afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`).
|
||||
/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG
|
||||
@@ -36,38 +31,18 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
|
||||
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
|
||||
}
|
||||
|
||||
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
|
||||
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
|
||||
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
vec![
|
||||
("All Formats", open_filter_extensions(OPEN_EXTS)),
|
||||
("PhotoCraft", open_filter_extensions(&["pcraft"])),
|
||||
("OpenRaster", open_filter_extensions(&["ora"])),
|
||||
("Krita", open_filter_extensions(&["kra", "krz"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
}
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
|
||||
/// keeps defaulting to PSD.
|
||||
/// keeps defaulting to Photoshop.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PSD Document", &["psd", "psb"]),
|
||||
("PDF", &["pdf"]),
|
||||
("Photoshop", &["psd", "psb"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg"]),
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
|
||||
("BMP", &["bmp"]),
|
||||
("GIF", &["gif"]),
|
||||
("ICO", &["ico"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
("DPX", &["dpx"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -83,11 +58,8 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
|
||||
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
|
||||
Some(i) => {
|
||||
let (name, mut exts) = v.remove(i);
|
||||
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
|
||||
exts.retain(|e| *e != ext);
|
||||
exts.insert(0, ext);
|
||||
v.insert(0, (name, exts));
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
}
|
||||
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
|
||||
None => {}
|
||||
@@ -95,21 +67,6 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
|
||||
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
|
||||
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
|
||||
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
|
||||
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
|
||||
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
|
||||
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
|
||||
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(suggested);
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
|
||||
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
|
||||
}
|
||||
|
||||
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
|
||||
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
|
||||
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
|
||||
@@ -119,19 +76,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
dialog = dialog.set_parent(parent);
|
||||
}
|
||||
let answer: Pin<Box<dyn Future<Output = Option<FileDialogAnswer>> + Send>> = match request {
|
||||
FileDialogRequest::Open { multiple, initial_dir, extensions } => {
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
// A command-specific filter must not inherit the image formats used by File › Open.
|
||||
let dialog = if let Some(exts) = extensions {
|
||||
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
|
||||
} else {
|
||||
open_filters()
|
||||
.into_iter()
|
||||
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
|
||||
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
|
||||
};
|
||||
FileDialogRequest::Open { multiple } => {
|
||||
let dialog = dialog.add_filter("All Formats", &open_filter_extensions(OPEN_EXTS)).add_filter("PhotoCraft", &open_filter_extensions(&["pcraft"]));
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
|
||||
@@ -140,33 +86,19 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
}
|
||||
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
if let Some(name) = Path::new(&suggested).file_name() {
|
||||
dialog = dialog.set_file_name(name.to_string_lossy());
|
||||
}
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
}
|
||||
};
|
||||
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
|
||||
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// An empty answer from a system with no dialog backend isn't a Cancel (#3354).
|
||||
let answer = block_on(answer).or_else(|| crate::file_dialog_probe::known_problem().map(FileDialogAnswer::Unavailable));
|
||||
reply.send(answer)
|
||||
}) {
|
||||
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || reply.send(block_on(answer))) {
|
||||
log::error!("couldn't wait for the file dialog: {e}");
|
||||
}
|
||||
}
|
||||
@@ -223,53 +155,6 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
|
||||
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
|
||||
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
settings.io_options()
|
||||
}
|
||||
|
||||
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
|
||||
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
|
||||
const ENCODE_SHARE: f32 = 0.85;
|
||||
|
||||
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
|
||||
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
|
||||
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
|
||||
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
|
||||
fn save_through(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
ctl.progress(0.0, "Encoding");
|
||||
let opts = export_options(settings);
|
||||
let cancelled = || ctl.check().is_err();
|
||||
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
|
||||
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
|
||||
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
|
||||
ctl.commit()?;
|
||||
ctl.progress(ENCODE_SHARE, "Writing");
|
||||
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
|
||||
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
|
||||
ctl.progress(1.0, "");
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
fn save_file(
|
||||
doc: &Document,
|
||||
path: &str,
|
||||
settings: &photocraft_ui_egui::ExportSettings,
|
||||
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
|
||||
) -> Result<Vec<String>, String> {
|
||||
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
|
||||
}
|
||||
|
||||
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
|
||||
|
||||
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
|
||||
@@ -283,9 +168,8 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
|
||||
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
|
||||
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
|
||||
fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let recover_dir = dir.clone();
|
||||
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
|
||||
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
|
||||
let (s1, s2, s3, s4) = (store.clone(), store.clone(), store.clone(), store.clone());
|
||||
Services {
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
|
||||
@@ -295,16 +179,8 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let _ = with_store(&s2, |s| s.discard(id));
|
||||
})),
|
||||
recover: Some(Box::new(move || {
|
||||
let Some(dir) = recover_dir.as_ref() else { return Vec::new() };
|
||||
photocraft_format::list_recovery(dir)
|
||||
.into_iter()
|
||||
.map(|e| Recoverable {
|
||||
key: e.info.key.clone(),
|
||||
name: e.info.document_name.clone(),
|
||||
path: e.info.original_path.clone(),
|
||||
load: Box::new(move || photocraft_format::recover(&e).map_err(|e| e.to_string())),
|
||||
})
|
||||
.collect()
|
||||
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
|
||||
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
|
||||
})),
|
||||
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
|
||||
let _ = with_store(&store, |s| s.adopt(id, key));
|
||||
@@ -336,43 +212,6 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
|
||||
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
|
||||
|
||||
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
|
||||
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
|
||||
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
|
||||
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
|
||||
image_from_offers(&offers, |mime| {
|
||||
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
|
||||
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
|
||||
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
|
||||
let bytes = fetch(mime)?;
|
||||
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
|
||||
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(&bytes).ok()?;
|
||||
Some((img.width(), img.height(), img.to_rgba8()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The shell command that starts this install under XWayland, where winit delivers file drops
|
||||
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
|
||||
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
|
||||
@@ -404,18 +243,6 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
|
||||
});
|
||||
let automation_open_pcraft = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
|
||||
let opened = crate::crash_guard::guard("Open", || workspace.open_pcraft(path).map_err(|e| e.to_string()))?;
|
||||
Ok(opened.map(|doc| (name, doc)))
|
||||
}) as photocraft_ui_egui::AutomationOpenPcraftFn
|
||||
});
|
||||
let automation_save = automation.clone().map(|workspace| {
|
||||
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
|
||||
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
|
||||
}) as photocraft_ui_egui::SaveFileFn
|
||||
});
|
||||
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
let automation_authorize = automation.is_some().then_some(step);
|
||||
let automation_command = automation.map(|_| {
|
||||
@@ -424,22 +251,26 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}) as photocraft_ui_egui::AutomationCommandFn
|
||||
});
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
crate::crash_guard::guard("Open", || {
|
||||
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]| {
|
||||
crate::crash_guard::guard("Open", || 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| {
|
||||
let opts = export_options(settings);
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
save_file: Some(Arc::new(save_file)),
|
||||
file_dialog: Some(Box::new(show_file_dialog)),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
automation_save,
|
||||
automation_command,
|
||||
automation_authorize,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
@@ -448,7 +279,6 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
})),
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
beep: Some(platform_beep),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
@@ -473,19 +303,12 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
}
|
||||
if let Ok(img) = cb.get_image() {
|
||||
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
return wayland_clipboard_image();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})
|
||||
}),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
system_theme: crate::appearance::service(),
|
||||
append_text: Some(Box::new(|path: &str, text: &str| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
|
||||
@@ -500,21 +323,6 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences ▸ General ▸ Beep When Done: the platform's default information beep.
|
||||
fn platform_beep() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// No beep API among this app's other platforms' dependencies: write the terminal bell
|
||||
// (audible when launched from a terminal, which is the same cue macOS terminals use).
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
|
||||
#[allow(dead_code)]
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
@@ -614,9 +422,6 @@ mod tests {
|
||||
assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}");
|
||||
}
|
||||
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
|
||||
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
|
||||
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
|
||||
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
@@ -624,7 +429,6 @@ mod tests {
|
||||
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
|
||||
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
|
||||
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
|
||||
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -717,65 +521,9 @@ mod tests {
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
for (name, ext) in asked.iter().zip(exts) {
|
||||
// The first type's first extension is the one Windows adds to the bare name.
|
||||
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
}
|
||||
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
|
||||
}
|
||||
|
||||
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
|
||||
#[test]
|
||||
fn save_as_offers_only_writable_formats_including_ico() {
|
||||
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
|
||||
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| !layered.contains(e)) {
|
||||
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
|
||||
assert!(format.caps().write, "{name}: .{ext} can't be written");
|
||||
}
|
||||
}
|
||||
for ext in ["ico", "gif", "bmp"] {
|
||||
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
|
||||
}
|
||||
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
|
||||
// What Save As writes there: an icon up to 256×256, and a clear error past that.
|
||||
let doc = |side: u32| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
|
||||
s.active().unwrap().doc.clone()
|
||||
};
|
||||
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
|
||||
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
|
||||
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
|
||||
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains("256"), "{err}");
|
||||
}
|
||||
|
||||
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
|
||||
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
|
||||
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
|
||||
#[test]
|
||||
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
|
||||
for (suggested, name, first) in [
|
||||
("/pics/image.psd", "image", "psd"),
|
||||
("/pics/image.psb", "image", "psb"),
|
||||
("/pics/image.pcraft", "image", "pcraft"),
|
||||
("/pics/sky.fit", "sky", "fit"),
|
||||
("cat.jpeg", "cat", "jpeg"),
|
||||
("swatches.aco", "swatches", "aco"),
|
||||
] {
|
||||
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
|
||||
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
|
||||
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
|
||||
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
|
||||
}
|
||||
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
|
||||
// extension, and a dotfile.
|
||||
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
|
||||
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
|
||||
}
|
||||
assert_eq!(dialog_file_name("", true), None);
|
||||
assert_eq!(save_filters("Untitled")[0].0, "Photoshop", "no extension keeps the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -802,16 +550,7 @@ mod tests {
|
||||
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
|
||||
/// already queued finish, nothing else runs).
|
||||
fn launch(dir: &Path) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())));
|
||||
let ctx = egui::Context::default();
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while app.session.has_jobs() && std::time::Instant::now() < deadline {
|
||||
photocraft_ui_egui::jobs_ui::tick(&mut app, &ctx);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(!app.session.has_jobs(), "recovery finished");
|
||||
app
|
||||
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
|
||||
}
|
||||
|
||||
/// The first pixel of each open document, and whether it's unsaved.
|
||||
@@ -896,7 +635,6 @@ mod tests {
|
||||
let mut app = launch(&recovery);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
|
||||
autosave(&mut app, &ctx);
|
||||
|
||||
let mut failed = false;
|
||||
@@ -917,16 +655,12 @@ mod tests {
|
||||
std::fs::remove_file(&recovery).unwrap();
|
||||
std::fs::create_dir(&recovery).unwrap();
|
||||
autosave(&mut app, &ctx);
|
||||
// The worker commits the bundle's manifest before it publishes the sidecar, and
|
||||
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
|
||||
// the first non-empty listing can come from the middle of the write. Wait for the write
|
||||
// to finish: the entry then carries the sidecar's revision.
|
||||
let mut recovered = Vec::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
recovered = list_recovery(&recovery);
|
||||
if recovered.iter().any(|e| e.info.revision == revision) {
|
||||
if !recovered.is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
@@ -1001,51 +735,6 @@ mod tests {
|
||||
assert!(image_from_files(&[bad]).is_none());
|
||||
}
|
||||
|
||||
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_png_clipboard_images_paste() {
|
||||
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
|
||||
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
|
||||
let mut asked = Vec::new();
|
||||
let got = image_from_offers(&offers, |mime| {
|
||||
asked.push(mime.to_string());
|
||||
Some(bmp.clone())
|
||||
});
|
||||
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
|
||||
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
|
||||
|
||||
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
|
||||
let offers = vec!["image/jpeg".to_string()];
|
||||
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_clipboard_offers_fall_through_to_the_next() {
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
|
||||
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
|
||||
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
|
||||
let got = image_from_offers(&offers, |mime| match mime {
|
||||
"image/webp" => None,
|
||||
"image/png" => Some(cut.clone()),
|
||||
"image/x-tga" => Some(b"not an image".to_vec()),
|
||||
_ => Some(bmp.clone()),
|
||||
});
|
||||
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
|
||||
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
|
||||
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_tga_is_trusted_only_by_its_type() {
|
||||
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_trusted_only_with_its_extension() {
|
||||
let dir = temp("tga");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -64,33 +64,15 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
|
||||
#[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).
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
|
||||
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
|
||||
#[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)
|
||||
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
|
||||
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
|
||||
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
|
||||
@@ -256,49 +238,6 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -27,23 +27,6 @@ pub fn sanitize(path: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -95,7 +78,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, saved_position};
|
||||
use super::check;
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
@@ -122,21 +105,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -134,8 +134,7 @@ fn main() {
|
||||
.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");
|
||||
assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release");
|
||||
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");
|
||||
@@ -165,31 +164,6 @@ fn main() {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -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,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,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,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 |
@@ -35,32 +35,6 @@ 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.
|
||||
@@ -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
|
||||
|
||||
@@ -11,11 +11,6 @@ PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.af
|
||||
- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers;
|
||||
- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept.
|
||||
|
||||
Artboards are recognized from both the legacy flag and current Affinity 3 artboard properties.
|
||||
They keep their names, bounds and editable children when saved as `.pcraft` or PSD; moving an
|
||||
artboard moves its children together. Nested boards become masked groups, and rotated or curved
|
||||
boards currently use their bounding rectangle.
|
||||
|
||||
The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is
|
||||
listed in a warning when the file opens, for example layer effects, adjustment layers and live
|
||||
filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as
|
||||
|
||||
@@ -17,17 +17,7 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
|
||||
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
|
||||
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
|
||||
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
|
||||
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
|
||||
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
|
||||
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
|
||||
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
|
||||
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
|
||||
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
|
||||
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
@@ -37,8 +27,8 @@ separations, black generation or native CMYK blending, and is not a claim of Pho
|
||||
|---|---:|
|
||||
| Maximum width | 262,144 pixels |
|
||||
| Maximum height | 262,144 pixels |
|
||||
| Maximum pixel count | 1,073,741,824 pixels |
|
||||
| Maximum decoded allocation | 8 GiB on 64-bit targets; 2 GiB on 32-bit targets (including wasm32) |
|
||||
| Maximum pixel count | 268,435,456 pixels |
|
||||
| Maximum decoded allocation | 2 GiB |
|
||||
|
||||
Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `image`-based decoders, while other implementations perform explicit checked dimension/buffer validation. Tests cover bomb-like headers, truncation, bit flips, header overwrites, and random data.
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
PhotoCraft uses the Rust workspace at the repository root. Install the stable Rust toolchain, clone the repository, and run commands from that root.
|
||||
|
||||
On Linux, install the windowing, GL and GTK development packages first. The development guide's
|
||||
[Prerequisites](https://github.com/storytold/photocraft/blob/main/docs/development.md#prerequisites)
|
||||
list them for Debian/Ubuntu, Fedora, Arch and openSUSE.
|
||||
|
||||
```sh
|
||||
cargo build --workspace
|
||||
cargo run --release -p photocraft -- image.psd
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity-checklist.md`](https://github.com/storytold/photocraft/blob/main/docs/parity-checklist.md) by `cargo xtask parity`.
|
||||
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity.md`](https://github.com/storytold/photocraft/blob/main/docs/parity.md) by `cargo xtask parity`.
|
||||
|
||||
Security hardening is cross-cutting rather than a replacement for those product milestones. The current security sequence is:
|
||||
|
||||
|
||||
@@ -7,15 +7,10 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
|
||||
- Headless TCP refuses a successfully bound non-loopback address.
|
||||
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
|
||||
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
|
||||
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
|
||||
headless/MCP batches to 256 steps.
|
||||
- Capability-scoped automation file opens read in chunks without reserving from the file's
|
||||
metadata length. A read rejects data beyond the length observed on the opened file, so growth
|
||||
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
|
||||
size ceiling; their materialized bytes and decoded document still require available memory.
|
||||
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
|
||||
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
|
||||
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
|
||||
are limited to 16, and headless/MCP batches to 256 steps.
|
||||
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
|
||||
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
|
||||
transport enforces the same request/reply ceilings as TCP.
|
||||
@@ -24,9 +19,7 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
|
||||
retains its existing behavior.
|
||||
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
|
||||
reply. Edits are never automatically resent after a transport failure (#1007); the server's
|
||||
pure-read tools opt into a single retry on a fresh connection, which applies nothing.
|
||||
Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
|
||||
downscaling; the 5 MiB PNG limit applies to the returned image.
|
||||
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
|
||||
- The bridge and UI request paths use timeouts.
|
||||
@@ -54,9 +47,8 @@ Automation is a privilege boundary because requests can cause filesystem access,
|
||||
- no general per-client or per-tool capability model beyond filesystem read/write authority;
|
||||
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
|
||||
`prefs.set`;
|
||||
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
|
||||
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
|
||||
or command-duration/cancellation budget;
|
||||
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
|
||||
scratch-space accounting, or command-duration/cancellation budget;
|
||||
- one thread per accepted TCP connection;
|
||||
- no structured security audit event stream;
|
||||
- desktop screenshot capture/encoding and document import/export have no automation-specific
|
||||
|
||||
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
|
||||
|
||||
### Native format
|
||||
|
||||
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
|
||||
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
|
||||
|
||||
## Review checklist
|
||||
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.jonathanwxh-cell]
|
||||
display_name = "jonathnwxh"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Skyfuller1303]
|
||||
display_name = "Skyfuller"
|
||||
sync_github_name = true
|
||||
|
||||
[people.AlanRockefeller]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -56,109 +48,5 @@ real_name = "Willy Foo"
|
||||
display_name = "Willy Foo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SilentYeti]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Elvis33LE]
|
||||
sync_github_name = true
|
||||
|
||||
[people.stevanmarjanovic]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Hathor875]
|
||||
real_name = "Krzysztof Cieślik"
|
||||
sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
sync_github_name = true
|
||||
|
||||
[people.skraus2]
|
||||
sync_github_name = true
|
||||
|
||||
[people.excited-raccoon]
|
||||
sync_github_name = true
|
||||
|
||||
[people.hamada147]
|
||||
real_name = "Ahmed Moussa"
|
||||
display_name = "Ahmed Moussa"
|
||||
sync_github_name = false
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
display_name = "Alexander Alber"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Erica4fr]
|
||||
sync_github_name = true
|
||||
|
||||
[people.itsF4LCON]
|
||||
real_name = "Ran Geerts"
|
||||
display_name = "Ran Geerts"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FinnNk]
|
||||
sync_github_name = true
|
||||
|
||||
[people.TheTeslak]
|
||||
sync_github_name = true
|
||||
|
||||
[people.timothydaly1]
|
||||
display_name = "SyntaxError"
|
||||
|
||||
[people.atoslins]
|
||||
real_name = "Atos Lins"
|
||||
display_name = "Atos Lins"
|
||||
sync_github_name = false
|
||||
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FredApps]
|
||||
sync_github_name = true
|
||||
|
||||
[people.arthurdeka]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aweussom]
|
||||
real_name = "Tommy Leonhardsen"
|
||||
|
||||
[people.Furash]
|
||||
sync_github_name = true
|
||||
|
||||
[people.lucAmbr0]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Hevarh1]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.deadmeu]
|
||||
sync_github_name = true
|
||||
|
||||
[people.megaDon101]
|
||||
display_name = "megadon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sssehnsuchttt]
|
||||
sync_github_name = true
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
sync_github_name = false
|
||||
|
||||
[people.yeniufuklar-com]
|
||||
real_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.sidney]
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
@@ -16,7 +16,7 @@ synth = []
|
||||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9.1"
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -13,16 +13,16 @@ Affinity files.
|
||||
| Affinity | PhotoCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart |
|
||||
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
|
||||
| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | shape layers |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
|
||||
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
|
||||
| Vector masks (`AdCh` curves and shapes) | vector masks |
|
||||
| Pixel masks (`MRst`, attached or as a layer in a group) | layer masks |
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | shape fill (converted to RGB) |
|
||||
| Linear, elliptical, radial and conical gradients with Affinity's midpoint bias | gradient fills |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradient fills |
|
||||
| Strokes: weight, scale with object, dash pattern, alignment, caps, joins, miter limit | shape strokes |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | type layers (rendered with the fonts installed) |
|
||||
@@ -33,12 +33,13 @@ Affinity files.
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
|
||||
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
|
||||
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
|
||||
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
|
||||
profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend modes (as Normal).
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients, special shapes
|
||||
(cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than
|
||||
round, stars with rounded points, several fills or strokes on one object (one of each is used),
|
||||
strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without
|
||||
the document's profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend
|
||||
modes (as Normal).
|
||||
|
||||
Opened Affinity documents start without a save path: Save asks for a new copy and never writes
|
||||
back over the Affinity file. When the native document can't be read at all, `photocraft-io` opens
|
||||
@@ -132,10 +133,8 @@ test fixtures, not an exporter.
|
||||
|
||||
## Safety limits
|
||||
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
|
||||
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
|
||||
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
|
||||
zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||||
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
|
||||
@@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = photocraft_affinity::container::header(data);
|
||||
let _ = photocraft_affinity::preview(data);
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
|
||||
fuzz_target!(|stream: &[u8]| {
|
||||
let _ = photocraft_affinity::stream::parse(stream);
|
||||
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -26,16 +26,11 @@ pub struct Limits {
|
||||
pub max_entry: usize,
|
||||
/// Total bytes extracted over the whole import.
|
||||
pub max_total: usize,
|
||||
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
|
||||
/// temporary planes the layer being read decodes through. The entry limits bound what is
|
||||
/// extracted, not what the layers materialized out of it cost (#4960).
|
||||
pub max_pixel_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
}
|
||||
};
|
||||
let b = s.floats::<4>(box_owner, b"ShpB")?;
|
||||
crate::shapes::shape(r, shape, b, world)
|
||||
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive, limits).document()
|
||||
Reader::new(&s, &mut archive).document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
@@ -298,27 +298,11 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
/// far hold).
|
||||
pixels_left: usize,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
|
||||
}
|
||||
|
||||
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
|
||||
/// output, which stays with the document; `planes` is the working set it decodes through and
|
||||
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
|
||||
/// three together.
|
||||
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
|
||||
let peak = rgba.saturating_add(planes);
|
||||
if peak > self.pixels_left {
|
||||
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
|
||||
}
|
||||
self.pixels_left -= rgba;
|
||||
Ok(())
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
@@ -447,13 +431,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
b"Scop" => Kind::Layer,
|
||||
b"Grup" => Kind::Group,
|
||||
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
|
||||
// Current .af boards carry artboard properties (aprp) in phrp; older
|
||||
// documents use ABEn. Converted curve boards keep those properties too.
|
||||
// Other property classes also use phrp, so the field alone is not a marker.
|
||||
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
|
||||
if class != Tag::of(b"ShpN") {
|
||||
self.warn("a converted artboard outline (imported at its bounding box)");
|
||||
}
|
||||
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
|
||||
return Ok(self.artboard(id, world));
|
||||
}
|
||||
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
|
||||
|
||||
@@ -117,11 +117,6 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
|
||||
// and the planes it decodes through before either is allocated (#4960).
|
||||
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
|
||||
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
|
||||
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
for c in 1..=channels {
|
||||
|
||||
@@ -43,7 +43,7 @@ fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
|
||||
}
|
||||
|
||||
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
|
||||
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<(Path, bool)> {
|
||||
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
|
||||
let [x0, y0, x1, y1] = b;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
|
||||
@@ -51,44 +51,28 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
};
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
// its own radius and type, clockwise from the top left.
|
||||
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
|
||||
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
|
||||
let i = if locked { 0 } else { i };
|
||||
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
|
||||
});
|
||||
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
} else {
|
||||
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
|
||||
r.warn("rectangles with concave or other corner types (imported with round corners)");
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
cornered_rect(
|
||||
b,
|
||||
corners.map(|(f, t)| {
|
||||
let (crx, cry) = if !absolute {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
} else if sx > 0.0 && sy > 0.0 {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
(f / sx, f / sy)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
(crx, cry, t == Some(1))
|
||||
}),
|
||||
)
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
@@ -131,21 +115,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
|
||||
// large enough that floating-point addition no longer changes the endpoint.
|
||||
let start = raw_start.rem_euclid(TAU);
|
||||
let delta = raw_end - raw_start;
|
||||
let sweep = if raw_end >= raw_start {
|
||||
delta.min(TAU)
|
||||
} else if delta.is_finite() {
|
||||
delta.rem_euclid(TAU)
|
||||
} else {
|
||||
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
|
||||
};
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, start + sweep, inner)
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
@@ -155,201 +131,12 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
|
||||
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
b"ShCl" => cloud(cx, cy, rx, ry, sides(r, shape, b"Bubl", 12.0)),
|
||||
b"ShHt" => heart(b),
|
||||
b"ShCg" => cog(r, shape, cx, cy, rx, ry),
|
||||
b"ShCR" => callout_rect(r, shape, b, false),
|
||||
b"ShCE" => callout_ellipse(r, shape, cx, cy, rx, ry),
|
||||
b"ShDA" => arrow(r, shape, b),
|
||||
b"ShDS" => double_star(r, shape, cx, cy, rx, ry),
|
||||
b"ShTr" => tear(r, shape, b),
|
||||
b"ShCr" => crescent(cx, cy, rx, ry),
|
||||
b"ShpD" => polygon([pt(cx, y0), pt(x1, cy), pt(cx, y1), pt(x0, cy)])?,
|
||||
b"ShSg" => segment(r, shape, cx, cy, rx, ry),
|
||||
_ => {
|
||||
r.warn("an unrecognized parametric shape (imported as its bounding ellipse)");
|
||||
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
|
||||
ellipse(cx, cy, rx, ry)
|
||||
}
|
||||
};
|
||||
let mut path = Path { subpaths: vec![sub] };
|
||||
if class == Tag::of(b"ShCg") {
|
||||
let hole = num(r, shape, b"Hole").unwrap_or(0.3).clamp(0.0, 0.9);
|
||||
if hole > 0.0 {
|
||||
path.subpaths.push(ellipse(cx, cy, rx * hole, ry * hole));
|
||||
}
|
||||
return Some((path, true));
|
||||
}
|
||||
Some((path, false))
|
||||
}
|
||||
|
||||
fn cloud(cx: f64, cy: f64, rx: f64, ry: f64, bubbles: usize) -> SubPath {
|
||||
let n = bubbles.clamp(3, 128) * 4;
|
||||
let points: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = TAU * i as f64 / n as f64 - FRAC_PI_2;
|
||||
let lobe = (0.5 - 0.5 * (TAU * i as f64 / 4.0).cos()).powi(2);
|
||||
let radius = 0.78 + 0.22 * lobe;
|
||||
pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let segments = (0..n)
|
||||
.map(|i| {
|
||||
let p0 = points[(i + n - 1) % n];
|
||||
let p1 = points[i];
|
||||
let p2 = points[(i + 1) % n];
|
||||
let p3 = points[(i + 2) % n];
|
||||
[pt(p1.x + (p2.x - p0.x) / 6.0, p1.y + (p2.y - p0.y) / 6.0), pt(p2.x - (p3.x - p1.x) / 6.0, p2.y - (p3.y - p1.y) / 6.0), p2]
|
||||
})
|
||||
.collect();
|
||||
SubPath { start: points[0], segments, closed: true }
|
||||
}
|
||||
|
||||
fn heart([x0, y0, x1, y1]: [f64; 4]) -> SubPath {
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let p = |x: f64, y: f64| pt(x0 + x * w, y0 + y * h);
|
||||
let start = p(0.5, 0.92);
|
||||
let segments = vec![
|
||||
[p(0.38, 0.85), p(0.04, 0.67), p(0.04, 0.37)],
|
||||
[p(0.04, 0.02), p(0.39, 0.00), p(0.5, 0.27)],
|
||||
[p(0.61, 0.00), p(0.96, 0.02), p(0.96, 0.37)],
|
||||
[p(0.96, 0.67), p(0.62, 0.85), start],
|
||||
];
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
fn cog(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let teeth = sides(r, shape, b"Teth", 12.0);
|
||||
let tooth = num(r, shape, b"TtSz").unwrap_or(0.3).clamp(0.05, 0.9);
|
||||
let notch = num(r, shape, b"NtSz").unwrap_or(0.45).clamp(0.05, 0.95);
|
||||
let tip = 1.0;
|
||||
let root = (1.0 - 0.2 * tooth - 0.08 * notch).clamp(0.55, 0.95);
|
||||
let mut pts = Vec::with_capacity(teeth * 4);
|
||||
for i in 0..teeth {
|
||||
let base = TAU * i as f64 / teeth as f64 - FRAC_PI_2;
|
||||
for (offset, radius) in [(-0.5, root), (-0.32, tip), (0.32, tip), (0.5, root)] {
|
||||
let a = base + offset * TAU / teeth as f64;
|
||||
pts.push(pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin()));
|
||||
}
|
||||
}
|
||||
polygon(pts).unwrap_or_else(|| rectangle_sub(cx - rx, cy - ry, cx + rx, cy + ry))
|
||||
}
|
||||
|
||||
fn callout_rect(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4], _ellipse: bool) -> SubPath {
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let tail_h = num(r, shape, b"TlHg").unwrap_or(0.2).clamp(0.0, 0.8) * h;
|
||||
let tail_w = num(r, shape, b"TlWd").unwrap_or(0.1).clamp(0.02, 0.8) * w;
|
||||
let tip_x = x0 + num(r, shape, b"TlRP").unwrap_or(0.5).clamp(0.0, 1.0) * w;
|
||||
let base_x = x0 + num(r, shape, b"TlEP").unwrap_or(0.4).clamp(0.0, 1.0) * w;
|
||||
let a = (base_x - tail_w / 2.0).clamp(x0, x1);
|
||||
let c = (base_x + tail_w / 2.0).clamp(x0, x1);
|
||||
let body_bottom = y1 - tail_h;
|
||||
let radius = (w.min(body_bottom - y0) * 0.12).max(0.0);
|
||||
// The three contours overlap inside the fill; no internal edge is visible in a filled shape.
|
||||
// Join the tail to the rounded body in one closed outline to avoid a seam in stroked shapes.
|
||||
let start = pt(x0 + radius, y0);
|
||||
let segments = vec![
|
||||
[pt(x1 - radius, y0), pt(x1 - radius, y0), pt(x1 - radius, y0)],
|
||||
[pt(x1 - (1.0 - KAPPA) * radius, y0), pt(x1, y0 + (1.0 - KAPPA) * radius), pt(x1, y0 + radius)],
|
||||
[pt(x1, body_bottom - radius), pt(x1, body_bottom - radius), pt(x1, body_bottom - radius)],
|
||||
[pt(x1, body_bottom), pt(x1, body_bottom), pt(x1 - radius, body_bottom)],
|
||||
[pt(c + radius * 0.2, body_bottom), pt(tip_x, y1 - tail_h * 0.15), pt(tip_x, y1)],
|
||||
[pt(tip_x, y1), pt(tip_x, y1), pt(tip_x, y1)],
|
||||
[pt(tip_x, y1 - tail_h * 0.15), pt(a - radius * 0.2, body_bottom), pt(a, body_bottom)],
|
||||
[pt(x0 + radius, body_bottom), pt(x0, body_bottom), pt(x0, body_bottom - radius)],
|
||||
[pt(x0, y0 + radius), pt(x0, y0 + radius), pt(x0, y0 + radius)],
|
||||
[pt(x0, y0 + (1.0 - KAPPA) * radius), pt(x0 + (1.0 - KAPPA) * radius, y0), start],
|
||||
];
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
fn callout_ellipse(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let tail_h = num(r, shape, b"TlHg").unwrap_or(0.2).clamp(0.0, 0.8);
|
||||
let pos = num(r, shape, b"TlEP").unwrap_or(0.25).clamp(0.0, 1.0);
|
||||
let angle = num(r, shape, b"TlAn").unwrap_or(0.0);
|
||||
let a0 = pos * TAU - angle;
|
||||
let a1 = a0 + 0.35;
|
||||
let start = pt(cx + rx * a1.cos(), cy + ry * a1.sin());
|
||||
let end = pt(cx + rx * a0.cos(), cy + ry * a0.sin());
|
||||
let tip = pt(cx + rx * (a0 + 0.18).cos() * 1.08, cy + ry * (a0 + 0.18).sin() * (1.0 + tail_h));
|
||||
// A single ellipse-like outline with a short triangular tail at its lower edge.
|
||||
let mut segments = Vec::new();
|
||||
arc(cx, cy, rx, ry, a1, a0 + TAU, &mut segments);
|
||||
segments.push([end, end, tip]);
|
||||
segments.push([tip, tip, start]);
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
fn arrow(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4]) -> SubPath {
|
||||
let h = y1 - y0;
|
||||
let thick = num(r, shape, b"Thck").unwrap_or(0.35).clamp(0.05, 0.9) * h;
|
||||
let shaft = (y0 + y1 - thick) / 2.0;
|
||||
let lower = shaft + thick;
|
||||
let head_start = x1 - h * 0.45;
|
||||
polygon([pt(x0, shaft), pt(head_start, shaft), pt(head_start, y0), pt(x1, (y0 + y1) / 2.0), pt(head_start, y1), pt(head_start, lower), pt(x0, lower)])
|
||||
.unwrap_or_else(|| rectangle_sub(x0, shaft, x1, lower))
|
||||
}
|
||||
|
||||
fn double_star(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let n = sides(r, shape, b"Pnts", 6.0).saturating_mul(2).clamp(8, 64);
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.6).clamp(0.05, 0.95);
|
||||
let outer = num(r, shape, b"PRad").unwrap_or(0.9).clamp(0.2, 1.0);
|
||||
polygon((0..2 * n).map(|i| {
|
||||
let a = -FRAC_PI_2 + PI * i as f64 / n as f64;
|
||||
let radius = if i % 2 == 0 { outer } else { outer * inner };
|
||||
pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin())
|
||||
}))
|
||||
.unwrap_or_else(|| rectangle_sub(cx - rx, cy - ry, cx + rx, cy + ry))
|
||||
}
|
||||
|
||||
fn tear(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4]) -> SubPath {
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let bulge = num(r, shape, b"Ball").unwrap_or(0.25).clamp(0.0, 0.5);
|
||||
let bend = num(r, shape, b"Bend").unwrap_or(0.0).clamp(-0.5, 0.5);
|
||||
let top = pt((x0 + x1) / 2.0, y0);
|
||||
let bottom = pt((x0 + x1) / 2.0 + bend * w * 0.25, y1);
|
||||
let segments = vec![
|
||||
[pt(x0 + w * 0.25, y0 + h * 0.42), pt(x0 + w * (0.5 - bulge), y1 - h * 0.03), bottom],
|
||||
[pt(x1 - w * (0.5 - bulge), y1 - h * 0.03), pt(x1 - w * 0.25, y0 + h * 0.42), top],
|
||||
];
|
||||
SubPath { start: top, segments, closed: true }
|
||||
}
|
||||
|
||||
fn crescent(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let start = pt(cx, cy - ry);
|
||||
let mut segments = Vec::new();
|
||||
arc(cx, cy, rx, ry, -FRAC_PI_2, -3.0 * FRAC_PI_2, &mut segments);
|
||||
// The inner arc returns from the lower tip to the upper tip, leaving a crescent open to the right.
|
||||
let ix = cx + rx * 0.72;
|
||||
let irx = rx * 0.86;
|
||||
let iry = ry * 0.82;
|
||||
let from = pt(ix, cy + iry);
|
||||
segments.push([from, from, from]);
|
||||
let mut inner = Vec::new();
|
||||
arc(ix, cy, irx, iry, FRAC_PI_2, -FRAC_PI_2, &mut inner);
|
||||
segments.extend(inner);
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
fn segment(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
// Keep hostile but finite angles in a small range before constructing arc controls.
|
||||
let angle = num(r, shape, b"Angl").unwrap_or(0.0).rem_euclid(TAU);
|
||||
let p0 = num(r, shape, b"Pos0").unwrap_or(0.25).clamp(0.0, 1.0);
|
||||
let p1 = num(r, shape, b"Pos1").unwrap_or(0.75).clamp(0.0, 1.0);
|
||||
let a0 = angle + (p0 - 0.5) * TAU;
|
||||
let mut a1 = angle + (p1 - 0.5) * TAU;
|
||||
while a1 < a0 {
|
||||
a1 += TAU;
|
||||
}
|
||||
let mut segments = Vec::new();
|
||||
arc(cx, cy, rx, ry, a0, a1.min(a0 + TAU), &mut segments);
|
||||
let start = pt(cx + rx * a0.cos(), cy + ry * a0.sin());
|
||||
let end = pt(cx + rx * a1.cos(), cy + ry * a1.sin());
|
||||
segments.push([end, end, start]);
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
fn rectangle_sub(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
|
||||
rectangle(x0, y0, x1, y1)
|
||||
Some(Path { subpaths: vec![sub] })
|
||||
}
|
||||
|
||||
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
|
||||
@@ -370,31 +157,22 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
|
||||
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
|
||||
/// one side shrink in proportion until they do.
|
||||
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
|
||||
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
|
||||
let [tl, tr, br, bl] = corners;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
|
||||
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
|
||||
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
|
||||
// (where the corner leaves the previous side, its two handles, where it joins the next side)
|
||||
let arcs = [
|
||||
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
|
||||
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
|
||||
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
|
||||
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
|
||||
];
|
||||
let mut segments = Vec::with_capacity(8);
|
||||
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
|
||||
segments.push([s, s, s]);
|
||||
if s != e {
|
||||
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
|
||||
}
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
|
||||
@@ -251,9 +251,9 @@ mod archive {
|
||||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
@@ -349,192 +349,3 @@ mod archive {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod shapes {
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind, Path, Point, SubPath};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
|
||||
/// its own, and fails the test when reading takes more than 20 s.
|
||||
fn read(definitions: Vec<F>, top: u32) -> Document {
|
||||
let data = synth::stream(&[
|
||||
(tag(b"Defs"), F::Shared(definitions)),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
|
||||
}
|
||||
|
||||
fn path(document: &Document) -> &Path {
|
||||
match &document.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { path, .. } => path,
|
||||
other => panic!("expected shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
|
||||
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
|
||||
for subpath in &path(&document).subpaths {
|
||||
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
|
||||
assert!(subpath.segments.len() <= 20);
|
||||
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
|
||||
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
|
||||
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
|
||||
let node =
|
||||
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
|
||||
(path.subpaths[0].clone(), doc.warnings)
|
||||
}
|
||||
|
||||
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
|
||||
fn corner_types(ids: [u16; 4]) -> F {
|
||||
let mut raw = vec![0xaa];
|
||||
raw.extend(tag(b"CTyp").0.to_le_bytes());
|
||||
raw.extend(4u32.to_le_bytes());
|
||||
raw.extend(0u16.to_le_bytes());
|
||||
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
|
||||
F::Raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
|
||||
let p = |x: f64, y: f64| Point { x, y };
|
||||
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
|
||||
let corners = |lock: Option<bool>| {
|
||||
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
|
||||
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
|
||||
rectangle(fields)
|
||||
};
|
||||
|
||||
let (sub, warnings) = corners(Some(false));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(sub.start, p(5.0, 0.0));
|
||||
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
|
||||
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
|
||||
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
|
||||
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
|
||||
|
||||
// Locked (the default): every corner follows the top left one.
|
||||
let (sub, _) = corners(None);
|
||||
assert_eq!(sub.segments.len(), 8);
|
||||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
|
||||
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
|
||||
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
|
||||
mod pixels {
|
||||
use crate::Error;
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind};
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
|
||||
fn layer(id: u32, w: i32, h: i32) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Rstr")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
|
||||
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A document whose one page holds a pixel layer per `sizes` entry.
|
||||
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
|
||||
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
|
||||
let data = synth::stream(&[(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
)]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
fn imported_layers(d: &Document) -> usize {
|
||||
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
|
||||
}
|
||||
|
||||
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
|
||||
const ONE_LAYER: usize = 40 << 10;
|
||||
|
||||
#[test]
|
||||
fn a_pixel_layer_within_the_budget_imports() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
|
||||
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
|
||||
let bytes = document(&[(64, 64), (64, 64)]);
|
||||
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
|
||||
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
|
||||
// Two layers fit once the budget covers their pixels together.
|
||||
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
|
||||
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
|
||||
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
|
||||
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
|
||||
assert!(
|
||||
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
|
||||
"16 MiB of declared pixels materialized under a 1 MiB budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! Full filter timing, including tile reads, filtering, writeback and pruning.
|
||||
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_algorithms -- extrude 6000 4000 u8 3 output.bin
|
||||
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
|
||||
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
|
||||
use photocraft_algo::{FilterParams, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let case = args.first().map(String::as_str).unwrap_or("extrude");
|
||||
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
|
||||
Ok(match args.get(i) {
|
||||
Some(s) => s.parse()?,
|
||||
None => fallback,
|
||||
})
|
||||
};
|
||||
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
|
||||
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
|
||||
return Err("dimensions must be 1..10000, repeats 1..9".into());
|
||||
}
|
||||
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
|
||||
"u8" => SampleType::U8,
|
||||
"u16" => SampleType::U16,
|
||||
"f32" => SampleType::F32,
|
||||
_ => return Err("depth must be u8, u16 or f32".into()),
|
||||
};
|
||||
let extrude = |size, depth| FilterParams::Extrude {
|
||||
kind: photocraft_algo::ExtrudeType::Blocks,
|
||||
size,
|
||||
depth,
|
||||
level_based: false,
|
||||
solid_front: false,
|
||||
mask_incomplete: false,
|
||||
seed: 17,
|
||||
};
|
||||
let params = match case {
|
||||
"extrude" => extrude(30.0, 30.0),
|
||||
"extrude-dense" => extrude(8.0, 128.0),
|
||||
"extrude-small" => extrude(8.0, 64.0),
|
||||
"wind" => FilterParams::Wind { method: photocraft_algo::WindMethod::Wind, from_right: false, seed: 17 },
|
||||
"blast" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: false, seed: 17 },
|
||||
"blast-right" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: true, seed: 17 },
|
||||
"spin" => FilterParams::SpinBlur {
|
||||
pins: vec![photocraft_algo::SpinPin { radius_x: 1.0, radius_y: 1.0, blur_angle: 30.0, angle: 17.0, ..Default::default() }],
|
||||
},
|
||||
"spin-default" => FilterParams::SpinBlur { pins: vec![Default::default()] },
|
||||
"lens" | "lens-ca" | "trap" | "trap-wide" | "trap-default" => FilterParams::Invert,
|
||||
_ => return Err("unknown case".into()),
|
||||
};
|
||||
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
|
||||
"rgb" => ColorMode::Rgb,
|
||||
"gray" => ColorMode::Grayscale,
|
||||
"cmyk" => ColorMode::Cmyk,
|
||||
"lab" => ColorMode::Lab,
|
||||
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
|
||||
};
|
||||
if case.starts_with("trap") && mode != ColorMode::Cmyk {
|
||||
return Err("Trap needs CMYK samples".into());
|
||||
}
|
||||
let format = PixelFormat::new(mode, depth, true);
|
||||
let n = format.channels();
|
||||
let bounds = Rect::new(-17, -29, w - 17, h - 29);
|
||||
let mut source = Surface::new(format);
|
||||
for tile in bounds.tiles() {
|
||||
let r = tile.rect().intersect(&bounds);
|
||||
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
|
||||
for c in 0..n {
|
||||
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
|
||||
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
source.write_region(r, &pixels);
|
||||
}
|
||||
let run_filter = |area: Rect| match case {
|
||||
"lens" | "lens-ca" => photocraft_algo::lens::correct(
|
||||
&source,
|
||||
area,
|
||||
&photocraft_algo::lens::LensCorrection {
|
||||
distortion: 12.0,
|
||||
angle: 2.5,
|
||||
vignette_amount: 20.0,
|
||||
red_cyan: if case == "lens-ca" { 12.0 } else { 0.0 },
|
||||
blue_yellow: if case == "lens-ca" { -8.0 } else { 0.0 },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
"trap" | "trap-wide" | "trap-default" => {
|
||||
let mut data = source.read_region(area);
|
||||
photocraft_algo::trap::trap(
|
||||
&mut data,
|
||||
area.width() as usize,
|
||||
area.height() as usize,
|
||||
n,
|
||||
if case == "trap-wide" {
|
||||
50
|
||||
} else if case == "trap-default" {
|
||||
1
|
||||
} else {
|
||||
10
|
||||
},
|
||||
);
|
||||
let mut result = source.clone();
|
||||
result.write_region(area, &data);
|
||||
result.prune();
|
||||
result
|
||||
}
|
||||
_ => apply_in(&source, ¶ms, area, bounds, None, bounds),
|
||||
};
|
||||
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
|
||||
drop(run_filter(warm));
|
||||
let mut times = Vec::new();
|
||||
for run in 0..repeats {
|
||||
let start = Instant::now();
|
||||
let result = run_filter(bounds);
|
||||
let seconds = start.elapsed().as_secs_f64();
|
||||
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
|
||||
times.push(seconds);
|
||||
if run + 1 == repeats
|
||||
&& let Some(path) = args.get(5)
|
||||
{
|
||||
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
|
||||
for tile in bounds.tiles() {
|
||||
for sample in result.read_region(tile.rect().intersect(&bounds)) {
|
||||
file.write_all(&sample.to_bits().to_le_bytes())?;
|
||||
}
|
||||
}
|
||||
file.flush()?;
|
||||
}
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
if let Some(median) = times.get(times.len() / 2) {
|
||||
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! End-to-end Iris Blur timings; fixture construction and output drop are untimed.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3`
|
||||
//! An optional fourth argument models the UI's proxy factor (e.g. 1500 1000 5 4).
|
||||
use photocraft_algo::{FilterParams, IrisPin, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(fallback);
|
||||
let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 3).clamp(1, 20));
|
||||
let proxy_factor = number(4, 1).clamp(1, 8) as f32;
|
||||
let bounds = Rect::new(0, 0, w, h);
|
||||
for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] {
|
||||
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
// Write rows to avoid a second full-image fixture allocation.
|
||||
for y in 0..h {
|
||||
let row: Vec<f32> = (0..w)
|
||||
.flat_map(|x| {
|
||||
let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 };
|
||||
let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 };
|
||||
[hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a]
|
||||
})
|
||||
.collect();
|
||||
source.write_region(Rect::new(0, y, w, y + 1), &row);
|
||||
}
|
||||
let mut cases = vec![("medium", vec![IrisPin { blur: 80.0, angle: 27.0, ..IrisPin::default() }])];
|
||||
if sample == SampleType::U8 {
|
||||
cases.extend([
|
||||
("small", vec![IrisPin::default()]),
|
||||
("large", vec![IrisPin { blur: 300.0, ..IrisPin::default() }]),
|
||||
(
|
||||
"multiple",
|
||||
vec![
|
||||
IrisPin { x: 0.3, blur: 80.0, roundness: 65.0, ..IrisPin::default() },
|
||||
IrisPin { x: 0.8, angle: 73.0, blur: 40.0, ..IrisPin::default() },
|
||||
],
|
||||
),
|
||||
("focused", vec![IrisPin { radius_x: 5.0, radius_y: 5.0, blur: 80.0, ..IrisPin::default() }]),
|
||||
("blurred", vec![IrisPin { x: -5.0, blur: 80.0, ..IrisPin::default() }]),
|
||||
]);
|
||||
}
|
||||
for (label, mut pins) in cases {
|
||||
// Top-level one-pin blur scales in preview_document_with. Its nested
|
||||
// JSON pin list retains supplied amounts; model that existing path.
|
||||
if pins.len() == 1 {
|
||||
for pin in &mut pins {
|
||||
pin.blur /= proxy_factor;
|
||||
}
|
||||
}
|
||||
let p = FilterParams::IrisBlur { pins };
|
||||
let warm = apply_in(&source, &p, bounds, bounds, None, bounds);
|
||||
std::hint::black_box(&warm);
|
||||
drop(warm);
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let start = std::time::Instant::now();
|
||||
let output = apply_in(&source, &p, bounds, bounds, None, bounds);
|
||||
times.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(&output);
|
||||
drop(output);
|
||||
}
|
||||
println!("{w}x{h} {name} {label}: {times:?} ms");
|
||||
}
|
||||
}
|
||||
}
|
||||