diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index b10e65a75..5fc2092a9 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -1,16 +1,23 @@
-# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
-# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
+# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
on:
push:
branches: [main]
- pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - .github/workflows/freebsd.yml
+ - ".github/workflows/freebsd.yml"
+ - "packaging/freebsd/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "crates/**"
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/freebsd.yml"
+ - "packaging/freebsd/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "crates/**"
workflow_dispatch:
concurrency:
@@ -22,34 +29,8 @@ permissions:
jobs:
freebsd:
- name: FreeBSD 14 (build + test)
- runs-on: ubuntu-latest
- timeout-minutes: 300
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Optional font build input, so the Japanese font tests run (they skip without it).
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
- path: craft-fonts
- - name: Build and test in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
- with:
- release: "14.3"
- usesh: true
- cpu: 4
- mem: 12288
- # Nothing to bring back; copying the target dir out of the VM fails on large builds.
- copyback: false
- prepare: |
- pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
- curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
- run: |
- . "$HOME/.cargo/env"
- # The checkout is synced in under another owner; let tests that call git read it.
- git config --global --add safe.directory "*"
- rustc --version
- export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
- CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
- CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
+ with:
+ require-craft-fonts: true
+ craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 7ec855992..71d3b3063 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -1,7 +1,5 @@
-# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
-# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
-# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
-# searches (a few minutes).
+# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
+# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
@@ -21,64 +19,10 @@ concurrency:
jobs:
lint:
- timeout-minutes: 15
- runs-on: ubuntu-latest
- env:
- ACTIONLINT_VERSION: 1.7.12
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - name: Install tools
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
- curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
- | tar -xz -C "$RUNNER_TEMP" actionlint
- - name: actionlint
- run: |
- "$RUNNER_TEMP/actionlint" -color
- - name: shellcheck
- run: shellcheck packaging/*.sh packaging/*/*.sh
- - name: PowerShell
- shell: pwsh
- run: |
- foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
- $errors = $null
- [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
- if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
- Write-Output "ok $($f.Name)"
- }
- - name: XML, desktop entry, AppStream, Flatpak manifest
- run: |
- xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
- # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
- if grep -oE '' 'Value="InstallDirDlg"' '' \
- 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
- grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
- done
- # The completion page the Publish events point at must actually exist: a Publish alone
- # does not pull the dialog fragment into the MSI, and without the DialogRef the
- # installer finishes silently (issues #790, #892).
- grep -qF '' packaging/windows/lightcraft.wxs \
- || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
- desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
- sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
- appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
- python3 - <<'EOF'
- import yaml
- src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
- bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
- for m in (src, bundle):
- assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", 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
- - name: FreeBSD tarball layout (stub binaries)
- run: bash packaging/freebsd/package.sh --dry-run
+ # 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
+ with:
+ extra-checks-script: packaging/check-app-specific.sh
windows-install-folder:
runs-on: windows-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 392ee4eb7..e24229356 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,13 +1,5 @@
-# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
-# are configured) and creates or updates the draft GitHub Release "LightCraft v".
-# See docs/releasing.md.
-#
-# The signing jobs (macOS, Windows) and the draft-release job 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. The jobs that sign
-# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
-# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
-# drafts a release.
+# Release pipeline: every push to `release` builds signed installers for every platform and
+# creates or updates the draft GitHub Release "LightCraft v". See docs/releasing.md.
name: Release
on:
@@ -21,479 +13,33 @@ on:
default: ""
permissions:
- contents: read
+ contents: write
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
- LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
- INPUT_VERSION: ${{ inputs.version }}
-
jobs:
- version:
- timeout-minutes: 10
- name: version
- runs-on: ubuntu-latest
- # No `environment: release` here or on the other jobs that sign nothing: no release secrets.
- outputs:
- version: ${{ steps.v.outputs.version }}
- date: ${{ steps.v.outputs.date }}
- prerelease: ${{ steps.v.outputs.prerelease }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - 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 "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
-
- macos:
- timeout-minutes: 90
- 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:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- path: craft-fonts
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- targets: aarch64-apple-darwin, x86_64-apple-darwin
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: release-macos-universal
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$LIGHTCRAFT_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:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- CRAFT_FONTS_REQUIRED: "1"
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
- APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- run: packaging/macos/package.sh --arch universal
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- 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:
- timeout-minutes: 90
- 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:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- path: craft-fonts
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: release-windows-${{ matrix.arch }}
- - name: Apply version override
- if: inputs.version != ''
- shell: bash
- run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- # package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- - name: Install WiX
- shell: pwsh
- run: dotnet tool install --global wix --version 5.0.2
- - name: Build, sign, package
- shell: pwsh
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- CRAFT_FONTS_REQUIRED: "1"
- WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
- WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
- AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
- AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
- AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
- AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
- AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
- run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-windows-${{ matrix.arch }}
- path: dist/release/*
- if-no-files-found: error
-
- linux:
- timeout-minutes: 90
- 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 }}
- 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:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- NFPM_VERSION: 2.47.0
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- 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@686976e191b89faba57d3206551f0f330d8cb249 # stable
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: release-linux-${{ matrix.arch }}
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$LIGHTCRAFT_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/lightcraft$|metainfo|applications'
- ldd target/release/lightcraft
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
- for appimage in dist/release/*.AppImage; do
- chmod +x "$appimage"
- APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
- # Update information 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-linux-${{ matrix.arch }}
- path: dist/release/*
- if-no-files-found: error
-
- flatpak:
- timeout-minutes: 90
- 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 }}
- strategy:
- fail-fast: false
- matrix:
- include:
- - { arch: x86_64, runner: ubuntu-24.04 }
- - { arch: aarch64, runner: ubuntu-24.04-arm }
- env:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- 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/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-flatpak-${{ matrix.arch }}
- path: dist/release/*.flatpak
- if-no-files-found: error
-
- linux-riscv64:
- timeout-minutes: 90
- name: Linux riscv64
- needs: version
- # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
- # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
- # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
- # CLI is smoke-tested under QEMU.
- runs-on: ubuntu-24.04
- # No `environment: release`: this job signs nothing, so it gets no release secrets.
- env:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- path: craft-fonts
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- targets: riscv64gc-unknown-linux-gnu
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: release-linux-riscv64
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- - name: Install cross-compilation dependencies
- run: |
- . /etc/os-release
- sudo dpkg --add-architecture riscv64
- # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
- if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
- sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
- fi
- if [ -f /etc/apt/sources.list ]; then
- sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
- sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
- fi
- for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
- echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
- done | sudo tee /etc/apt/sources.list.d/riscv64.list
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
- libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
- libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- - name: Build and package (tar.gz)
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- CRAFT_FONTS_REQUIRED: "1"
- CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
- PKG_CONFIG_SYSROOT_DIR: /
- PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
- PKG_CONFIG_ALLOW_CROSS: "1"
- CROSS_ARCH: riscv64
- CROSS_TARGET: riscv64gc-unknown-linux-gnu
- CROSS_COMPILE: riscv64-linux-gnu-
- EMULATOR: qemu-riscv64
- QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
- run: packaging/linux/package.sh --formats tar
- - name: Check the tarball
- run: |
- tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
- | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-linux-riscv64
- path: dist/release/*.tar.gz
- 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
- env:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- 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 # v1.5.9
- with:
- release: "14.3"
- usesh: true
- cpu: 4
- mem: 12288
- # Passed into the VM by name; never templated into the script below.
- envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_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/lightcraft-target CARGO_BUILD_JOBS=4
- if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_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/lightcraft-*-freebsd-x86_64.tar.gz \
- | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-freebsd-x86_64
- path: dist/release/*
- if-no-files-found: error
-
- web:
- timeout-minutes: 90
- name: Web (wasm)
- needs: version
- runs-on: ubuntu-latest
- env:
- LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
- LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- WASM_BINDGEN_VERSION: 0.2.129
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- # Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: storytold/craft-fonts
- ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately
- path: craft-fonts
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: release-web
- - name: Install wasm-bindgen CLI
- run: |
- # Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
- if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
- cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
- fi
- wasm-bindgen --version
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$LIGHTCRAFT_VERSION"
- - name: Build and zip
- env:
- CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
- CRAFT_FONTS_REQUIRED: "1"
- run: packaging/web/package.sh
- - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: release-web
- path: dist/release/*
- if-no-files-found: error
-
release:
- timeout-minutes: 10
- name: Draft GitHub Release
- needs: [version, macos, windows, linux, linux-riscv64, 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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- 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="LightCraft 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"
+ # 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: lightcraft
+ app-title: LightCraft
+ craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
+ 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 }}
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 1cccd858b..0033346dc 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -1,22 +1,27 @@
-# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
-# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
-# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
-# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
+# Windows ARM64 build and smoke test.
+#
+# Release packages on arm64-windows are produced on x64 runners via cross-compilation
+# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
+# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
on:
push:
branches: [main]
- pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "**/build.rs"
- - .github/workflows/windows-arm64.yml
- - packaging/windows/**
- schedule:
- - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
+ - ".github/workflows/windows-arm64.yml"
+ - "packaging/windows/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "crates/**"
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/windows-arm64.yml"
+ - "packaging/windows/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "crates/**"
workflow_dispatch:
concurrency:
@@ -26,70 +31,10 @@ concurrency:
permissions:
contents: read
-env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: 0
-
jobs:
- package:
- name: package (cross-compiled on x64)
- runs-on: windows-latest
- timeout-minutes: 90
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
- with:
- targets: aarch64-pc-windows-msvc
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- with:
- key: windows-arm64-package
- - name: Install WiX
- shell: pwsh
- run: dotnet tool install --global wix --version 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
- with:
- name: lightcraft-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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
- with:
- name: lightcraft-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 'LightCraft'
- foreach ($exe in 'lightcraft.exe', 'lightcraft-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 'lightcraft-cli.exe'
- $out = & $cli --version 2>&1 | Out-String
- if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
- Write-Output "ok lightcraft-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 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
- Write-Output 'ok uninstalled'
+ 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: lightcraft
+ app-dir: LightCraft
diff --git a/Cargo.lock b/Cargo.lock
index 4b6d54289..30ce9e1f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1072,6 +1072,11 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -3073,7 +3078,7 @@ dependencies = [
[[package]]
name = "lightcraft"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"eframe",
"egui",
@@ -3098,7 +3103,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3115,7 +3120,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3131,7 +3136,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3161,7 +3166,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3170,7 +3175,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3187,7 +3192,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3202,7 +3207,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3214,9 +3219,10 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"ab_glyph",
+ "craft-docpath",
"harfrust",
"jxl-oxide",
"libc",
@@ -3256,7 +3262,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
@@ -3271,7 +3277,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"log",
"rustls",
@@ -3282,7 +3288,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3290,7 +3296,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"proptest",
"serde",
@@ -3298,7 +3304,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3318,7 +3324,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"heic-rs",
"rayon",
@@ -3326,14 +3332,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3342,7 +3348,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3353,7 +3359,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3369,7 +3375,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3381,7 +3387,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3399,7 +3405,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3411,7 +3417,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"libc",
"lightcraft-color",
@@ -3423,7 +3429,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"dhat",
"jpeg-encoder",
@@ -3447,7 +3453,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"image",
"lightcraft-color",
@@ -3458,7 +3464,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -3472,7 +3478,7 @@ dependencies = [
[[package]]
name = "lightcraft-slideshow"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"lightcraft-raster",
"serde",
@@ -3481,15 +3487,15 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
-version = "0.8.0"
+version = "0.9.1"
[[package]]
name = "lightcraft-testkit"
-version = "0.8.0"
+version = "0.9.1"
[[package]]
name = "lightcraft-tiff"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"proptest",
"serde",
@@ -3498,7 +3504,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"craft-ui",
"egui",
@@ -3527,7 +3533,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"crc32fast",
"eframe",
@@ -6997,7 +7003,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.8.0"
+version = "0.9.1"
dependencies = [
"brotli",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index ac8cbeca1..cf21739d8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.8.0"
+version = "0.9.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs
index d180d013d..4d16c2f4d 100644
--- a/crates/catalog/src/dates.rs
+++ b/crates/catalog/src/dates.rs
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option {
if !(first..=last).contains(&t) {
return None;
}
- let tail = iso.trim().get(19..).unwrap_or("");
+ let iso = iso.trim();
+ // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
+ let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
@@ -222,6 +224,15 @@ mod tests {
assert_eq!(normalize_iso("2026-02-30"), None);
}
+ #[test]
+ fn shift_keeps_the_zone_of_a_time_without_seconds() {
+ assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
+ assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
+ assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
+ }
+
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index d1548f8bd..311f6dcde 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -173,11 +173,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
- _ => false,
+ // Not a known field: the colon is part of a plain word ("10:30", "Re:").
+ _ => text_matches(p, &t),
};
}
+ text_matches(p, &t)
+}
+
+/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
+/// format or a keyword of `p`.
+fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
- hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
+ hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs
index a713b7736..eb7e89ef4 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -294,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
- let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
+ let (hh, mm, ss) =
+ if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
// days from civil (proleptic Gregorian)
let y2 = if m <= 2 { y - 1 } else { y };
let era = y2.div_euclid(400);
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 8f1aaf151..255417098 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -123,6 +123,27 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
+/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
+/// text, not a fielded token that matches nothing.
+#[test]
+fn free_text_with_a_colon_searches_the_text_fields() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
+ let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.caption = "Meeting at 10:30 with Re: notes".into();
+ m.iso = Some(1600);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("meeting"), vec![a]);
+ assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
+ assert_eq!(q("re:"), vec![a]);
+ assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
+ assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
+ assert_eq!(q("iso:>2000"), Vec::::new());
+ assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing");
+}
+
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 6ea5866b7..5902243fc 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
+Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
+become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
+preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
+
## Dependencies and licences
All pure Rust, verified with `cargo info` / `cargo tree`:
diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs
index 02652db5a..836aed548 100644
--- a/crates/codecs/src/convert.rs
+++ b/crates/codecs/src/convert.rs
@@ -44,11 +44,23 @@ impl Buf {
match self {
Buf::U8(v) => v[i] as f32 / 255.0,
Buf::U16(v) => v[i] as f32 / 65535.0,
- Buf::F32(v) => v[i],
+ Buf::F32(v) => normalize_float(v[i]),
}
}
}
+/// Clip non-finite device samples before colour conversion, preserving finite extended range.
+#[inline]
+fn normalize_float(value: f32) -> f32 {
+ if value == f32::INFINITY {
+ 1.0
+ } else if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
/// Interleaved device samples as produced by a format decoder.
pub(crate) struct Raw {
pub width: usize,
@@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
- let s = |c: usize| {
- let val = v[i + c];
- if val.is_finite() { val } else { 0.0 }
- };
+ let s = |c: usize| normalize_float(v[i + c]);
*o = if gray {
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
[l, l, l]
diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs
new file mode 100644
index 000000000..fe1cd2411
--- /dev/null
+++ b/crates/codecs/tests/float_tiff.rs
@@ -0,0 +1,128 @@
+//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
+
+use lightcraft_codecs::{icc, *};
+
+fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec {
+ encode_tiff(
+ &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
+ TiffCompression::None,
+ &EncodeMeta { icc: profile, ..Default::default() },
+ )
+ .unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
+ let samples =
+ [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
+ let bytes = tiff(samples.as_flattened(), 3, None);
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert!(decoded.float);
+ assert_eq!(decoded.bit_depth, 32);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
+ let display = decoded.to_srgb8();
+ assert_eq!(display.data[0], [255; 4]);
+ for pixel in &display.data[1..4] {
+ assert_eq!(*pixel, [0, 0, 0, 255]);
+ }
+ assert_eq!(display.data[6], [255, 188, 0, 255]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // A box-filtered pair must average clipped white with its ordinary neighbor.
+ let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
+ let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
+ assert_eq!(small.image.data, [[0.625; 3]]);
+}
+
+#[test]
+fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
+ let profiles = [
+ None,
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
+ Some(icc::write_named(NamedSpace::Srgb)),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
+ Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
+ Some(b"invalid ICC profile".to_vec()),
+ ];
+ for channels in [1, 3] {
+ let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ for profile in &profiles {
+ let profile = profile.as_deref();
+ let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.grayscale, channels == 1);
+ assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
+ assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
+ assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
+ assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
+ }
+ }
+}
+
+#[test]
+fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
+ let samples = [
+ [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
+ [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
+ [0.25, 0.5, 2.0, f32::NEG_INFINITY],
+ [2.0, 3.0, 4.0, 0.5],
+ ];
+ let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // Associated alpha takes the same path before unpremultiplication.
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
+ let mut image = encoder.new_image::(2, 1).unwrap();
+ image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
+ image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
+ }
+ let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
+ assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
+}
+
+fn lut_profile() -> Vec {
+ use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
+ let mut profile = ColorProfile::new_srgb();
+ profile.cicp = None;
+ profile.red_colorant = Xyzd::default();
+ profile.green_colorant = Xyzd::default();
+ profile.blue_colorant = Xyzd::default();
+ profile.red_trc = None;
+ profile.green_trc = None;
+ profile.blue_trc = None;
+ // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
+ profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
+ b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ bias: Vector3d::default(),
+ }));
+ profile.encode().unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
+ let profile = lut_profile();
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
+ let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
+ assert_eq!(decoded.image, reference.image);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ed20cd605..091efe066 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
workspace = true
[dependencies]
+# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
+craft-docpath = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
index 676519d29..030cb6e93 100644
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -16,7 +16,7 @@ const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
- Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
+ Some(d.unix_seconds())
}
fn analyze(s: &mut Session, p: &Value) -> Result {
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 7b0e1e562..860cf089f 100644
--- a/crates/engine/src/cmd/library.rs
+++ b/crates/engine/src/cmd/library.rs
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec {
always, |_, p| {
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
- Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
+ Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
}),
cmd!(
"library.importLightroom",
diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs
index 1a1b8b23c..8af63cd9b 100644
--- a/crates/engine/src/cmd/library/tests_relative_ratings.rs
+++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
+ std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs
new file mode 100644
index 000000000..c2b0e9f69
--- /dev/null
+++ b/crates/engine/src/doc_path.rs
@@ -0,0 +1,63 @@
+//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
+//! originals a Lightroom catalog lists.
+//!
+//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
+//! document's folder, and only local files, or files on the network share the document itself is
+//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
+//! file.
+
+use std::fmt;
+use std::path::{Path, PathBuf};
+
+pub use craft_docpath::Refused;
+
+/// Why a stored path is not followed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NotFollowed {
+ /// The path is on another computer (other than the document's share), names a device, is a
+ /// URL, or is not a usable path.
+ Refused(Refused),
+ /// The path is not an absolute path on this computer, such as a Windows drive path read on
+ /// macOS or Linux.
+ NotHere,
+}
+
+impl fmt::Display for NotFollowed {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Refused(r) => r.fmt(f),
+ Self::NotHere => f.write_str("the path is not a path on this computer"),
+ }
+ }
+}
+
+impl std::error::Error for NotFollowed {}
+
+/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
+/// or why it is not followed. A relative `document` is taken from the working directory, where it
+/// was opened from.
+pub fn resolve(stored: &str, document: Option<&Path>) -> Result {
+ let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
+ let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
+ // A path that is still relative would be taken from the working directory, which the
+ // document does not choose.
+ if path.is_relative() {
+ return Err(NotFollowed::NotHere);
+ }
+ Ok(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
+ let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
+ assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
+ assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
+ assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
+ }
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 8a282ce3e..d6c721792 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -29,6 +29,7 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
+pub mod doc_path;
mod errors;
pub mod export;
pub mod face_download;
@@ -56,6 +57,7 @@ pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
+pub mod open_path;
pub mod originals;
mod pdf;
pub mod preset_import;
diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs
index 0dceb421d..5133fc80c 100644
--- a/crates/engine/src/lightroom_archive.rs
+++ b/crates/engine/src/lightroom_archive.rs
@@ -538,6 +538,7 @@ mod tests {
keywords: vec!["Trips|Goa".into()],
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
+ original: crate::lightroom_catalog::Original::Local,
};
CatalogImport {
source: "source.lrcat".into(),
@@ -621,6 +622,7 @@ mod tests {
keywords: Vec::new(),
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
snapshots: Vec::new(),
+ original: crate::lightroom_catalog::Original::Local,
})
.collect();
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs
index 777206bcb..5f39188a1 100644
--- a/crates/engine/src/lightroom_catalog.rs
+++ b/crates/engine/src/lightroom_catalog.rs
@@ -2,7 +2,7 @@
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
use std::collections::{BTreeMap, HashMap, HashSet};
-use std::path::{Component, Path, PathBuf};
+use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
pub keywords: Vec,
pub history: Vec,
pub snapshots: Vec,
+ /// Whether `path` may be looked at (derived when the catalog is read; not archived).
+ #[serde(skip)]
+ pub original: Original,
+}
+
+/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
+/// as the document ([`crate::doc_path::resolve`]).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum Original {
+ /// `path` is a local file path, or one on the network share the catalog is on.
+ Local,
+ /// `path` is not a path on this computer (a catalog made on another system): the photo is
+ /// kept as an offline record for later relinking, and the path is not looked at.
+ #[default]
+ NotHere,
+ /// `path` is refused (why): it is on another computer or a device, or its folder or file name
+ /// is an absolute path. The photo is reported missing and not added to the library.
+ Refused(String),
+}
+
+impl CatalogPhoto {
+ /// Whether the original is a file here; only a [`Original::Local`] path is looked at.
+ pub fn original_is_file(&self) -> bool {
+ self.original == Original::Local && Path::new(&self.path).is_file()
+ }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -343,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result {
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
-/// catalog's volume, resolved against the catalog's folder, if that is a folder.
+/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
+/// looked at only when [`crate::doc_path::resolve`] follows them.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option {
- if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
+ if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
return None;
}
- let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
- let mut out = PathBuf::new();
- for part in dir.components() {
- match part {
- Component::ParentDir => {
- if !out.pop() {
- return None;
- }
- }
- Component::CurDir => {}
- part => out.push(part),
- }
+ crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
+}
+
+/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
+/// place of the root folder.
+fn is_absolute_part(part: &str) -> bool {
+ let bytes = part.as_bytes();
+ part.starts_with(['/', '\\'])
+ || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
+ || Path::new(part).is_absolute()
+}
+
+/// The path of an original under `root` and whether it may be looked at, resolved with the
+/// catalog at `catalog` as the document.
+fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
+ let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
+ if is_absolute_part(folder) || is_absolute_part(name) {
+ return (stored, Original::Refused("its folder or file name is an absolute path".into()));
+ }
+ match crate::doc_path::resolve(&stored, Some(catalog)) {
+ Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
+ Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
+ Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
}
- out.is_dir().then_some(out)
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
@@ -457,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out
};
let mut root_paths = HashMap::new();
+ let mut refused_roots = HashSet::new();
let mut root_ids: Vec<_> = roots.keys().copied().collect();
root_ids.sort_unstable();
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
@@ -469,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
}
None => absolute.to_string(),
};
+ if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
+ warnings
+ .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
+ refused_roots.insert(id);
+ }
root_paths.insert(id, resolved);
}
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
@@ -487,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
- let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
+ let root_id = number(root, "id_local");
+ let root_path = root_paths.get(&root_id).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
return Err(format!("image {id}: incomplete original path"));
}
- let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
+ let (photo_path, original) = original_path(root_path, folder_path, name, path);
+ if let Original::Refused(why) = &original
+ && !refused_roots.contains(&root_id)
+ {
+ out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
+ }
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
Ok(s) => s,
Err(e) => {
@@ -504,19 +552,64 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out.photos.push(CatalogPhoto {
source_id: id,
uuid: text(&image, "id_global").into(),
- path,
+ path: photo_path,
image,
settings: develops.get(&id).cloned().unwrap_or_default(),
xmp: packet,
keywords: keywords.remove(&id).unwrap_or_default(),
history: history.remove(&id).unwrap_or_default(),
snapshots: snapshots.remove(&id).unwrap_or_default(),
+ original,
});
}
+ refuse_non_media(&mut out);
ensure_read_active(cancel)?;
Ok(out)
}
+/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
+/// import's warnings.
+///
+/// The catalog decides these paths, and a record it leaves behind is one click from the system
+/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
+/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
+/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
+/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
+///
+/// Called where the catalog is read and again where its photos become records, so neither route
+/// can keep one; the second pass finds nothing left to say.
+pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
+ // masters first: a virtual copy may come before its master in the table
+ let refused: HashSet = data
+ .photos
+ .iter()
+ .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
+ .map(|p| p.source_id)
+ .collect();
+ if refused.is_empty() {
+ return;
+ }
+ let mut warnings = Vec::new();
+ data.photos.retain(|p| {
+ let master = number(&p.image, "masterImage");
+ // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
+ // take every master of the catalog with it
+ if master != 0 {
+ if !refused.contains(&master) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
+ return false;
+ }
+ if !refused.contains(&p.source_id) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
+ false
+ });
+ data.warnings.extend(warnings);
+}
+
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
@@ -665,11 +758,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
let paths: Vec<_> = data
.photos
.iter()
- .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
+ .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
.map(|p| p.path.clone())
.collect();
let missing_sources: HashSet =
- data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
+ data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
let applied =
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
@@ -692,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
-pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
+pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
+ // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
+ refuse_non_media(&mut data);
validate(&data).map_err(error)?;
let mut warnings = data.warnings.clone();
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
@@ -713,12 +808,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
+ let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
let mut planned: HashMap = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
+ // A refused path must not become a library record the app would look at later; its
+ // virtual copies go with it.
+ if refused.contains(&src.source_id) || refused.contains(&master) {
+ continue;
+ }
let indexed = index
.photos
.get(&identity(&data, src))
@@ -940,6 +1041,7 @@ mod tests {
keywords: vec!["Travel|Goa".into()],
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
snapshots: Vec::new(),
+ original: Original::Local,
};
let mut copy = photo.clone();
copy.source_id = 2;
@@ -962,6 +1064,48 @@ mod tests {
warnings: Vec::new(),
}
}
+ /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
+ /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
+ /// copies go with it; the catalog's real photos still import.
+ #[test]
+ fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
+ let mut data = sample();
+ let mut exe = data.photos[1].clone();
+ exe.source_id = 3;
+ exe.uuid = "payload".into();
+ exe.path = "/Users/sky/Downloads/payload.exe".into();
+ exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
+ let mut exe_copy = exe.clone();
+ exe_copy.source_id = 4;
+ exe_copy.uuid = "payload-copy".into();
+ exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
+ exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
+ // the copy comes before its master, as a catalog's table order may have it
+ data.photos.insert(0, exe_copy);
+ data.photos.push(exe);
+ for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
+ let mut one = data.clone();
+ one.photos.retain(|p| p.source_id < 3);
+ let mut planted = one.photos[1].clone();
+ planted.source_id = 5;
+ planted.uuid = "planted".into();
+ planted.path = bad.into();
+ planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
+ one.photos.push(planted);
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, one, false).unwrap();
+ assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
+ }
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, data, false).unwrap();
+ assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
+ let warnings = r["warnings"].to_string();
+ assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
+ assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
+ }
+
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
@@ -1508,11 +1652,57 @@ mod tests {
// a relative path that leads nowhere keeps the recorded one
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
let catalog = read(&path).unwrap();
- assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
+ assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
std::fs::remove_dir_all(dir).unwrap();
}
+ #[test]
+ fn originals_on_other_computers_are_never_looked_at_or_added() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("Photos")).unwrap();
+ std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
+ let source = dir.join("source.lrcat");
+ let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
+ if cfg!(unix) {
+ // `///` is a local folder to Linux, but a network path to the resolver.
+ roots.push(format!("/{}/", dir.display()));
+ }
+ for root in &roots {
+ std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
+ assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
+ let mut s = crate::Session::new();
+ let probes = Arc::new(AtomicUsize::new(0));
+ let calls = probes.clone();
+ s.media.file_probe = Some(Arc::new(move |_| {
+ calls.fetch_add(1, Ordering::Relaxed);
+ Err("a refused original must not be probed".into())
+ }));
+ let report = import(&mut s, &source, false).unwrap();
+ assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
+ assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
+ assert!(s.catalog.is_empty(), "{root}");
+ }
+ // An absolute folder path would replace the root.
+ let mut tables = photo_tables((dir.to_str().unwrap(), ""));
+ tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
+ std::fs::write(&source, native_catalog(&tables)).unwrap();
+ assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
+ // A root beside the catalog is followed.
+ std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert_eq!(catalog.photos[0].original, Original::Local);
+ assert!(catalog.photos[0].original_is_file());
+ // A root that moved is found through its path relative to the catalog.
+ std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
#[test]
fn oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
index 8de1cc806..52a51b30f 100644
--- a/crates/engine/src/lightroom_job.rs
+++ b/crates/engine/src/lightroom_job.rs
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
if !seen.insert(key.clone()) {
continue;
}
- if Path::new(&photo.path).is_file() {
+ if photo.original_is_file() {
paths.push(photo.path.clone());
} else {
missing.insert(key);
@@ -393,6 +393,7 @@ mod tests {
keywords: Vec::new(),
history: Vec::new(),
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}],
collections: Vec::new(),
members: Vec::new(),
@@ -531,6 +532,7 @@ mod tests {
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
)])],
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}
})
.collect();
diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs
new file mode 100644
index 000000000..d80039729
--- /dev/null
+++ b/crates/engine/src/open_path.rs
@@ -0,0 +1,117 @@
+//! Whether a path the library holds may be handed to the operating system.
+//!
+//! A photo's `Source::File` path is not always something a user typed: it can come from an
+//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
+//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
+//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
+//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
+//!
+//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
+//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
+//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
+//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
+//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
+//!
+//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
+//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
+//! refused original never becomes a library record in the first place.
+
+use std::path::Path;
+
+/// The absolute path to hand to an opener or external editor, or the reason it is refused.
+///
+/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
+/// the path's own file name.
+pub fn openable(path: &str, label: &str) -> Result {
+ let p = Path::new(path);
+ let name = match label.trim() {
+ "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
+ l => l.to_string(),
+ };
+ if !is_media(p) {
+ return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
+ }
+ match std::fs::metadata(p) {
+ Ok(m) if m.is_file() => {}
+ Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
+ Err(e) => return Err(format!("the original is missing: {path} ({e})")),
+ }
+ // a link may name a photo and point at a program; what runs is what it points at
+ if let Ok(target) = p.canonicalize()
+ && !is_media(&target)
+ {
+ return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
+ }
+ let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
+ Ok(absolute.to_string_lossy().into_owned())
+}
+
+/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
+pub fn is_media(path: &Path) -> bool {
+ crate::import::is_supported(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write(dir: &Path, name: &str) -> String {
+ let p = dir.join(name);
+ std::fs::write(&p, b"x").unwrap();
+ p.to_string_lossy().into_owned()
+ }
+
+ #[test]
+ fn only_file_types_lightcraft_reads_are_handed_over() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
+ let path = write(&dir, good);
+ assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
+ }
+ for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
+ let path = write(&dir, bad);
+ let e = openable(&path, "").unwrap_err();
+ assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
+ }
+ // a bundle (macOS `.app`) and any other folder are not files
+ let bundle = dir.join("Evil.app");
+ std::fs::create_dir_all(&bundle).unwrap();
+ let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
+ let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("missing"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A relative path is made absolute: the opener runs with its own working directory.
+ #[test]
+ fn the_path_handed_over_is_absolute() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = write(&dir, "a.jpg");
+ let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
+ let out = openable(&dotted.to_string_lossy(), "").unwrap();
+ assert!(Path::new(&out).is_absolute(), "{out}");
+ assert!(out.ends_with("a.jpg"), "{out}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A link named like a photo that points at a program is refused: what the opener runs is
+ /// the target, not the name.
+ #[cfg(unix)]
+ #[test]
+ fn a_link_to_a_program_is_refused() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let target = write(&dir, "payload.command");
+ let link = dir.join("holiday.jpg");
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+ let e = openable(&link.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
index ea06d69a7..cdee1e894 100644
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -4,6 +4,8 @@
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
+//! A sidecar is written only beside an original that is an existing file at an absolute path;
+//! otherwise the save fails.
//!
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
@@ -632,6 +634,10 @@ impl Session {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
+ // A relative path would be taken from the working directory.
+ if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
+ return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
+ }
let naming = owners.naming(p, self.xmp.naming);
let preferred = sidecar_path(orig, naming);
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
@@ -786,6 +792,30 @@ impl Session {
#[cfg(test)]
mod tests {
+ #[test]
+ fn sidecars_are_written_only_beside_an_existing_original() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
+ let originals = [
+ (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
+ (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
+ (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
+ ];
+ for (original, written) in originals {
+ let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
+ let mut s = Session::new();
+ let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
+ assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
+ assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
+ assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
/// gives the photo its keywords as paths.
#[test]
diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs
index 409739335..a27f75d49 100644
--- a/crates/engine/src/slide_overlay.rs
+++ b/crates/engine/src/slide_overlay.rs
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
let my0 = (y - pad).floor().max(-pad);
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
- if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
+ if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
return;
}
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs
index 23969f505..0ac8df804 100644
--- a/crates/engine/src/tests_export.rs
+++ b/crates/engine/src/tests_export.rs
@@ -1024,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let mut written = Vec::new();
- let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
- written.push(p.to_string());
- Ok(())
+ // the writer lives in a block of its own, so its borrow of `written` has ended by the time
+ // the assertion below reads it
+ let files = {
+ let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
+ written.push(p.to_string());
+ Ok(())
+ };
+ let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
+ assert!(err.contains("no folder on disk"), "{err}");
+ // without stopping: one error per photo, nothing written
+ let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
+ crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
};
- let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
- assert!(err.contains("no folder on disk"), "{err}");
- // without stopping: one error per photo, nothing written
- let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
- let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
assert_eq!(files.len(), 2);
for (f, id) in files.iter().zip(&ids) {
assert_eq!(f["photo"], id.0, "{f}");
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
}
- // (`write` borrows `written` until its last use above; a closure has no `Drop` to wait for)
assert!(written.is_empty(), "{written:?}");
// a photo on disk whose folder is gone (moved, an unplugged drive)
diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs
index 3d6469deb..13f02ce77 100644
--- a/crates/engine/src/tests_import.rs
+++ b/crates/engine/src/tests_import.rs
@@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
+/// every month, including those with fewer than 31 days.
+#[test]
+fn assisted_culling_groups_bursts_across_month_end() {
+ let dir = temp_dir("cull-month-end");
+ for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
+ let img = lightcraft_scenes::demo_library()[0].render(w, h);
+ let data: Vec<[u8; 4]> = img
+ .data
+ .iter()
+ .map(|c| {
+ let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
+ [e(c[0]), e(c[1]), e(c[2]), 255]
+ })
+ .collect();
+ let png = lightcraft_codecs::encode_png(
+ &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
+ &Default::default(),
+ )
+ .unwrap();
+ std::fs::write(dir.join(name), png).unwrap();
+ }
+ let mut s = Session::new().with_fs();
+ let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
+ let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
+ let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
+ let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
+ for (before, after) in [
+ ("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
+ ("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
+ ("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
+ ("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
+ ("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
+ ("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
+ ("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
+ ] {
+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
+ let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
+ assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
#[test]
fn dust_spots_are_found_and_healed() {
diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs
index 89fe86822..de0bea5c9 100644
--- a/crates/geocode/src/lib.rs
+++ b/crates/geocode/src/lib.rs
@@ -104,7 +104,8 @@ pub struct GeoNames {
fn cell(p: LatLon) -> (i32, i32) {
// valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range
- (p.lat.floor() as i32, p.lon.floor() as i32)
+ // longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes
+ (p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180)
}
fn short(s: &str) -> String {
@@ -253,6 +254,14 @@ mod tests {
assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X");
}
+ #[test]
+ fn city_on_the_antimeridian_is_found() {
+ let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None);
+ for lon in [179.9, -179.9, 180.0, -180.0] {
+ assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}");
+ }
+ }
+
#[test]
fn high_latitude_cities_two_cells_away() {
let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None);
diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs
index d7ea85122..241a23d6b 100644
--- a/crates/ui-egui/src/lightroom_import.rs
+++ b/crates/ui-egui/src/lightroom_import.rs
@@ -233,7 +233,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) ->
json!({
"photos": data.photos.len(),
"collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(),
- "missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::>(),
+ "missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::>(),
"warnings": data.warnings,
})
}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 9fcfc8b9d..d575d7490 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -2144,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(),
"dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(),
"photo.openOriginalInExternal" => {
+ // only a file type LightCraft reads can be handed over (#2135); the extension alone
+ // decides, so no disk or share is touched while a menu is drawn
app.services.open_with.is_some()
- && app
- .session
- .active()
- .and_then(|id| app.session.catalog.photo(id))
- .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
+ && app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source {
+ lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)),
+ _ => false,
+ })
}
"app.showInFinder" => {
app.services.reveal.is_some()
@@ -2324,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft:
if let Some(id) = id {
app.ui.external_edits.push(*id);
}
- if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) {
- failed = Some(e);
+ // the copy is LightCraft's own render, but it goes through the same gate as any other
+ // path handed to the system opener (#2135)
+ match lightcraft_engine::open_path::openable(path, "") {
+ Err(e) => failed = Some(e),
+ Ok(absolute) => {
+ if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) {
+ failed = Some(e);
+ }
+ }
}
}
match (failed, copies.as_slice()) {
@@ -2377,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result {
let mut files = Vec::new();
let mut skipped = Vec::new();
for id in ids {
- match app.session.catalog.photo(id).map(|ph| &ph.source) {
- Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())),
- Some(lightcraft_engine::catalog::Source::File { path }) => {
- skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")}))
- }
+ // the path may come from an imported catalog: only a photo file LightCraft reads is
+ // handed to the opener, never a program (#2135)
+ match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) {
+ Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) {
+ Ok(absolute) => files.push((id, absolute)),
+ Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})),
+ },
Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})),
None => skipped.push(json!({"id": id.0, "reason": "no such photo"})),
}
diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs
index bbea5356e..4e397208e 100644
--- a/crates/ui-egui/src/tests_masking.rs
+++ b/crates/ui-egui/src/tests_masking.rs
@@ -1177,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() {
h.settle(SETTLE);
}
+/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the
+/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is
+/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling.
+#[test]
+fn open_original_refuses_anything_that_is_not_a_photo_file() {
+ use lightcraft_catalog::{Op, Photo, Source};
+ let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let opened: std::sync::Arc>> = Default::default();
+ let batches: std::sync::Arc>>> = Default::default();
+ let (o, bt) = (opened.clone(), batches.clone());
+ let services = Services {
+ png: None,
+ open_with: Some(Box::new(move |path: &str, app: &str| {
+ o.lock().unwrap().push((path.to_string(), app.to_string()));
+ Ok(())
+ })),
+ open_all_with: Some(Box::new(move |paths: &[String], _: &str| {
+ bt.lock().unwrap().push(paths.to_vec());
+ Ok(())
+ })),
+ check_editor: Some(Box::new(|_: &str| Ok(()))),
+ ..Default::default()
+ };
+ let mut session = lightcraft_engine::Session::with_demo().with_fs();
+ let mut record = |name: &str| {
+ let path = dir.join(name);
+ std::fs::write(&path, b"MZ").unwrap();
+ let id = session.catalog.alloc_photo_id();
+ let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00");
+ session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ id
+ };
+ // what a crafted catalog can name as a photo's original
+ let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"];
+ let bad: Vec<_> = programs.iter().map(|n| record(n)).collect();
+ let good = record("DSC_0001.NEF");
+ let app = LightcraftApp::new(session, services);
+ let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
+ h.app.ui.settings.external_editor = "DxO PhotoLab".into();
+
+ for (id, name) in bad.iter().zip(programs) {
+ let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T);
+ assert_eq!(r["ok"], false, "{name} was opened: {r}");
+ assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}");
+ // the menu item is greyed out for such a record, so it is never offered either
+ exec(&mut h, "library.select", json!({"ids": [id.0]}));
+ assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu");
+ }
+ assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program");
+
+ // a program selected together with a photo: only the photo is handed over, by absolute path
+ let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]}));
+ assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}");
+ assert!(r["skipped"].to_string().contains("payload.exe"), "{r}");
+ let handed = batches.lock().unwrap().clone();
+ assert_eq!(handed.len(), 1);
+ assert_eq!(handed[0].len(), 1, "{handed:?}");
+ assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}");
+ let _ = std::fs::remove_dir_all(&dir);
+ h.settle(SETTLE);
+}
+
#[test]
fn second_window_shows_the_active_photo() {
let mut h = detail("panel.edit");
diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md
index 5ad4383fd..b6c0014f0 100644
--- a/docs/lightroom-catalog-import.md
+++ b/docs/lightroom-catalog-import.md
@@ -97,3 +97,12 @@ the path relative to the catalog that Lightroom records for root folders on the
(`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying
a project folder with its catalog inside it therefore keeps its originals linked. Other moved
originals stay missing; use LightCraft's missing-photo relinking tools to update their paths.
+
+Original and root folder paths are resolved with the catalog as the document and are looked at
+only when they are on this computer or on the network share the catalog itself is on
+(`crates/engine/src/doc_path.rs`). An original on another computer (`\\server\share\…`,
+`//server/…`, `file://server/…`), a device path, or one whose folder or file name in the catalog is
+an absolute path is not looked at: the import lists it as missing with a warning and leaves the
+photo and its virtual copies out of the library. To import originals on a share, open the catalog
+from that share. A path that is not a path on this computer (a Windows drive path read on macOS or
+Linux) is not looked at either and stays catalogued as missing.
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
index cda8fe3c9..5fdaec083 100644
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy |
| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only |
| LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered |
-| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet |
+| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet |
## T. Share (SHARE)
diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh
new file mode 100644
index 000000000..c14ca5725
--- /dev/null
+++ b/packaging/check-app-specific.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# App-specific installer and package assertions retained alongside shared packaging lint.
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+
+for want in '' 'Value="InstallDirDlg"' '' \
+ 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
+ grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
+done
+grep -qF '' packaging/windows/lightcraft.wxs \
+ || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
+
+python3 - <<'PYEOF'
+import yaml
+manifests = []
+for name in ("ai.storyteller.lightcraft.yml", "ai.storyteller.lightcraft.bundle.yml"):
+ with open("packaging/linux/flatpak/" + name) as source:
+ manifest = yaml.safe_load(source)
+ assert manifest["id"] == "ai.storyteller.lightcraft" and manifest["command"] == "lightcraft", name
+ manifests.append(manifest)
+for key in ("runtime", "runtime-version", "sdk", "finish-args"):
+ assert manifests[0][key] == manifests[1][key], f"Flatpak manifests disagree on {key}"
+print("LightCraft package identity ok")
+PYEOF
diff --git a/packaging/web/package.sh b/packaging/web/package.sh
index 64b6d2149..16691a6ba 100755
--- a/packaging/web/package.sh
+++ b/packaging/web/package.sh
@@ -12,6 +12,15 @@ set -euo pipefail
HERE="$ROOT/packaging/web"
if [ "${1:-}" != "--skip-build" ]; then
+ # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the
+ # version in Cargo.lock. Locally, `cargo xtask web` says which version to install.
+ if [ -n "${CI:-}" ]; then
+ WASM_BINDGEN_VERSION="$(awk '$0 == "name = \"wasm-bindgen\"" { getline; gsub(/version = |"/, ""); print; exit }' "$ROOT/Cargo.lock")"
+ [ -n "$WASM_BINDGEN_VERSION" ] || { echo "error: wasm-bindgen not found in Cargo.lock" >&2; exit 1; }
+ if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF " $WASM_BINDGEN_VERSION"; }; then
+ cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
+ fi
+ fi
(cd "$ROOT" && cargo xtask web)
fi