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