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..447a20ec7 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"
@@ -3217,6 +3222,7 @@ name = "lightcraft-engine"
version = "0.8.0"
dependencies = [
"ab_glyph",
+ "craft-docpath",
"harfrust",
"jxl-oxide",
"libc",
diff --git a/Cargo.toml b/Cargo.toml
index ac8cbeca1..39fc8aaaf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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/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/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..1e9c18d99 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;
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 b18520b16..11952ba81 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,13 +552,14 @@ 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,
});
}
ensure_read_active(cancel)?;
@@ -670,11 +719,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 })?;
@@ -718,12 +767,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))
@@ -945,6 +1000,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;
@@ -1540,11 +1596,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/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/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/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md
index 1bc624828..824ed2a6a 100644
--- a/docs/lightroom-catalog-import.md
+++ b/docs/lightroom-catalog-import.md
@@ -103,6 +103,15 @@ the path relative to the catalog that Lightroom records for root folders on the
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.
-## Local validation
+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.
+
+## Historical local validation
Validated in a local Linux environment after upstream reconciliation: 47 catalog-focused tests, nine pipeline spot tests, the exact sparse-catalog regression, and all eight `cargo xtask ci` stages passed. The full gate recorded 3,166 passing test executions, zero failures, and 28 ignored tests. An original synthetic sparse catalog with a required image-table page beyond 1 GiB also passed headless application import and selection; its expected missing-original placeholder retained its five-star rating. This snapshot check does not establish native-window coverage or dense-catalog performance.
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