Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3b62745a | ||
|
|
8012979a1e | ||
|
|
5e67f026ec | ||
|
|
eeee3d920a | ||
|
|
f100db89ca | ||
|
|
2ce0fcc74a | ||
|
|
6f62ac09f8 | ||
|
|
c1e0f09be7 | ||
|
|
38a87c0bca | ||
|
|
693195fa96 | ||
|
|
43f70c4765 | ||
|
|
6609aa9acf | ||
|
|
b396096787 | ||
|
|
beb822dced | ||
|
|
ef1154af94 | ||
|
|
896188a489 | ||
|
|
d3b6127db6 | ||
|
|
369eb8fbf0 | ||
|
|
4574c35236 | ||
|
|
9cc52fd72b | ||
|
|
2836ec2881 | ||
|
|
fa4dca327f | ||
|
|
0a2c49dd10 | ||
|
|
b718c70b11 |
@@ -365,6 +365,112 @@ jobs:
|
|||||||
path: target-android/apk/darkroom.apk
|
path: target-android/apk/darkroom.apk
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
windows-image:
|
||||||
|
uses: ./.gitea/workflows/windows-image.yml
|
||||||
|
|
||||||
|
# TRACES: FR-PLAT-WIN-3
|
||||||
|
# The Windows executable and its installer, cross-built from Linux
|
||||||
|
# (docs/windows.md §7). No Windows machine anywhere in this job: what it
|
||||||
|
# can prove is that the binary links, is a Windows executable with no
|
||||||
|
# MinGW runtime imports, starts under Wine, and that the installer installs
|
||||||
|
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
|
||||||
|
# render, the secret store — is a release step on a real machine (§6).
|
||||||
|
windows:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: Windows (x86_64, cross)
|
||||||
|
needs: windows-image
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: 0
|
||||||
|
CARGO_TARGET_DIR: target-windows
|
||||||
|
# Wine keeps its prefix under $HOME, which the image points at a
|
||||||
|
# directory that does not exist in a fresh container.
|
||||||
|
HOME: /tmp/home
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Same step as the desktop leg: the models are LFS objects and the
|
||||||
|
# packager refuses pointers.
|
||||||
|
- name: Fetch the models
|
||||||
|
env:
|
||||||
|
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
git lfs install --local
|
||||||
|
git config --local --get-regexp '^http\..*extraheader$' \
|
||||||
|
| cut -d' ' -f1 | sort -u \
|
||||||
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
|
git config --local lfs.url \
|
||||||
|
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||||
|
git lfs pull
|
||||||
|
ls -l models/face models/scene
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/opt/cargo/registry
|
||||||
|
target-windows
|
||||||
|
key: windows-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
# The cfg(windows) branches are linted here and nowhere else: the
|
||||||
|
# desktop leg's clippy never compiles them.
|
||||||
|
- name: Clippy for the target
|
||||||
|
run: cargo clippy --release --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
|
||||||
|
- name: Smoke-test the executable
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
EXE=target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe
|
||||||
|
file "$EXE"
|
||||||
|
file "$EXE" | grep -q 'PE32+' || { echo "FAIL: not a PE32+ executable"; exit 1; }
|
||||||
|
file "$EXE" | grep -q '(GUI)' || { echo "FAIL: not a GUI-subsystem executable"; exit 1; }
|
||||||
|
if x86_64-w64-mingw32-objdump -p "$EXE" | grep -iE 'libwinpthread|libgcc|libstdc'; then
|
||||||
|
echo "FAIL: the executable imports a MinGW runtime DLL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
x86_64-w64-mingw32-objdump -p "$EXE" | grep 'DLL Name' | sort -u
|
||||||
|
wineboot --init >/dev/null 2>&1 || true
|
||||||
|
OUT=$(wine "$EXE" --version 2>/dev/null)
|
||||||
|
echo "wine: $OUT"
|
||||||
|
echo "$OUT" | grep -q '^darkroom-desktop ' || { echo "FAIL: --version did not answer under Wine"; exit 1; }
|
||||||
|
|
||||||
|
- name: Package the installer
|
||||||
|
run: bash docker/windows/package.sh
|
||||||
|
|
||||||
|
- name: Smoke-test the installer
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SETUP=$(ls target-windows/installer/DarkRoom-*-x86_64-setup.exe)
|
||||||
|
file "$SETUP" | grep -q 'PE32+' || { echo "FAIL: the installer is not 64-bit"; exit 1; }
|
||||||
|
wine "$SETUP" /S 2>/dev/null
|
||||||
|
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||||
|
ls "$INST"
|
||||||
|
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
|
||||||
|
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||||
|
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||||
|
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||||
|
|| { echo "FAIL: the installed executable does not run"; exit 1; }
|
||||||
|
wine "$INST/uninstall.exe" /S 2>/dev/null
|
||||||
|
sleep 3
|
||||||
|
[ ! -e "$INST" ] || { echo "FAIL: uninstall left $INST behind"; ls -R "$INST"; exit 1; }
|
||||||
|
echo "OK: installed and uninstalled under Wine"
|
||||||
|
|
||||||
|
- name: Upload the installer
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: darkroom-windows-x86_64-setup
|
||||||
|
path: target-windows/installer/DarkRoom-*-x86_64-setup.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
layering:
|
layering:
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
name: Layer separation
|
name: Layer separation
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: '🐳 Windows image'
|
||||||
|
|
||||||
|
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-windows, the job
|
||||||
|
# container for the Windows leg of build-and-test.yml.
|
||||||
|
#
|
||||||
|
# The same shape as android-image.yml, for the same reason that one exists:
|
||||||
|
# an image that lives only on a developer's laptop is a job that dies at
|
||||||
|
# `docker pull`. Built from docker/windows, tagged by that directory's tree
|
||||||
|
# id, skipped when the registry already has it.
|
||||||
|
#
|
||||||
|
# Called by build-and-test.yml on every push, and runnable by hand via
|
||||||
|
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
force:
|
||||||
|
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||||
|
type: string
|
||||||
|
default: 'false'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force:
|
||||||
|
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||||
|
type: string
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
# Gitea's act_runner mangles boolean workflow inputs passed through an
|
||||||
|
# expression — they arrive as false regardless of what was sent. Every input
|
||||||
|
# here is a string compared with == 'true', as in KPN's docker.yaml.
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-windows
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: Build and push
|
||||||
|
# Deliberately NOT in a container: this job needs the host Docker daemon to
|
||||||
|
# build an image, and the host's cached ~/.docker/config.json to push it.
|
||||||
|
# That is also why there is no `docker login` step — the runner host was
|
||||||
|
# authenticated to the registry during setup.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# The host has no Node, so the JS-based actions/checkout cannot run here.
|
||||||
|
# A minimal shallow fetch with plain git gets the same tree.
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
|
||||||
|
fetch --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# The image is tagged by the content of docker/windows, not by the commit
|
||||||
|
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
|
||||||
|
# id — it changes when and only when a file in that directory changes, so
|
||||||
|
# an unrelated push reuses the existing image and a Dockerfile edit can
|
||||||
|
# never silently keep serving a stale `latest`.
|
||||||
|
#
|
||||||
|
# Using the commit sha instead would rebuild 2.5 GB on every push; using a
|
||||||
|
# paths-filter action would need a container that has Node, and the only
|
||||||
|
# one this repo would reach for is the very image being built.
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TREE=$(git rev-parse HEAD:docker/windows)
|
||||||
|
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "docker/windows tree: $TREE"
|
||||||
|
|
||||||
|
# Skip the build when the registry already holds this exact content. This
|
||||||
|
# is what keeps the job a few seconds long on a normal push, and what
|
||||||
|
# makes it self-healing: if the tag is missing for any reason, including
|
||||||
|
# the image having never been pushed at all, it gets built here.
|
||||||
|
#
|
||||||
|
# The probe is curl against the registry API, NOT `docker manifest
|
||||||
|
# inspect`. The latter exits 1 on this registry even for tags that are
|
||||||
|
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
|
||||||
|
# API while `docker manifest inspect` reports "manifest unknown" for it.
|
||||||
|
# Trusting that would have rebuilt 7 GB on every single push.
|
||||||
|
#
|
||||||
|
# A HEAD request also gives the digest for free, which is how the repoint
|
||||||
|
# decision below is made without pulling any layers.
|
||||||
|
- name: Query registry
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
# The runner's own credentials, so this does not depend on how the
|
||||||
|
# host's ~/.docker/config.json happens to be set up.
|
||||||
|
REG_USER: ${{ github.actor }}
|
||||||
|
REG_PASS: ${{ github.token }}
|
||||||
|
TREE: ${{ steps.tag.outputs.tree }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
|
||||||
|
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-windows/manifests"
|
||||||
|
|
||||||
|
# Prints "<http-status> <digest-or-empty>" for a tag.
|
||||||
|
probe() {
|
||||||
|
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
|
||||||
|
| tr -d '\r' \
|
||||||
|
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
|
||||||
|
}
|
||||||
|
|
||||||
|
read -r TREE_STATUS TREE_DIGEST <<EOF
|
||||||
|
$(probe "$TREE")
|
||||||
|
EOF
|
||||||
|
read -r LATEST_STATUS LATEST_DIGEST <<EOF
|
||||||
|
$(probe latest)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
|
||||||
|
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
|
||||||
|
|
||||||
|
# Build unless the registry definitively confirms this content is
|
||||||
|
# already there. An auth failure or an unreachable registry lands
|
||||||
|
# here too, and rebuilding needlessly is the safe direction to fail —
|
||||||
|
# skipping a build that was needed is what breaks the Windows job.
|
||||||
|
if [ "${{ inputs.force }}" = "true" ]; then
|
||||||
|
echo "forced rebuild requested"
|
||||||
|
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "$TREE_STATUS" != "200" ]; then
|
||||||
|
echo "registry does not have this content — building"
|
||||||
|
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
|
||||||
|
echo "registry is already correct — nothing to do"
|
||||||
|
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "content is present but latest points elsewhere — repointing"
|
||||||
|
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repoint=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Context is docker/windows, matching the README's build command. The
|
||||||
|
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
|
||||||
|
# and a narrow context keeps the daemon from tarring up the whole tree,
|
||||||
|
# target/ included.
|
||||||
|
- name: Build
|
||||||
|
if: ${{ steps.check.outputs.build == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
docker/windows
|
||||||
|
|
||||||
|
# Both tags are pushed: the tree tag is what the guard above looks for on
|
||||||
|
# the next run, and `latest` is what build-and-test.yml pulls.
|
||||||
|
- name: Push
|
||||||
|
if: ${{ steps.check.outputs.build == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
|
||||||
|
# A cache hit on the tree tag says nothing about where `latest` points — a
|
||||||
|
# reverted Dockerfile or a build from another branch can leave it on
|
||||||
|
# different content. This runs only when the digests above actually
|
||||||
|
# disagree, so the common case costs nothing; the layers are already in
|
||||||
|
# the registry, so the push that follows uploads a manifest, not 2.5 GB.
|
||||||
|
- name: Repoint latest
|
||||||
|
if: ${{ steps.check.outputs.repoint == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||||
|
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
Generated
+35
-23
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1234,13 +1234,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-ui",
|
"dr-ui",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
|
"winresource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1407,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1424,7 +1425,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1439,7 +1440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1453,7 +1454,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1471,7 +1472,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
@@ -1484,7 +1485,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1493,7 +1494,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1510,7 +1511,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1522,7 +1523,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1530,7 +1531,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1539,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1555,7 +1556,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1565,7 +1566,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
@@ -1578,7 +1579,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1592,7 +1593,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1604,7 +1605,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1626,7 +1627,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1638,7 +1639,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1647,7 +1648,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1668,6 +1669,7 @@ dependencies = [
|
|||||||
"dr-sync-nextcloud",
|
"dr-sync-nextcloud",
|
||||||
"dr-thumbs",
|
"dr-thumbs",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
|
"dr-xmp",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
@@ -1686,7 +1688,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -6987,7 +6989,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -8387,6 +8389,16 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winresource"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||||
|
dependencies = [
|
||||||
|
"toml 1.1.4+spec-1.1.0",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -15,5 +15,13 @@ anyhow.workspace = true
|
|||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
|
# The Windows resource block — icon and version — compiled in by build.rs.
|
||||||
|
# Unconditional rather than under `[target.'cfg(windows)']`, because a cfg on
|
||||||
|
# a build-dependency is evaluated against the *host* — the machine running
|
||||||
|
# the build script — and this is built for Windows from Linux. The script
|
||||||
|
# itself returns before touching the crate on every other target.
|
||||||
|
[build-dependencies]
|
||||||
|
winresource = "0.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! TRACES: FR-PLAT-WIN-2
|
||||||
|
//! The Windows resource block: icon and version, compiled into the executable.
|
||||||
|
//!
|
||||||
|
//! Windows takes an application's icon and its "Details" tab from a resource
|
||||||
|
//! inside the `.exe`, not from a `.desktop` file, so without this the installed
|
||||||
|
//! program shows the generic executable icon in Explorer, the Start Menu and
|
||||||
|
//! the taskbar, and reports no version. Nothing here runs for any other
|
||||||
|
//! target: the whole body is behind the target-OS check, and the crate that
|
||||||
|
//! does the work is a build-dependency only.
|
||||||
|
//!
|
||||||
|
//! The icon is the same PNG every other platform uses, wrapped into an `.ico`
|
||||||
|
//! in `OUT_DIR` rather than committed: an ICO entry may *be* a PNG (Vista and
|
||||||
|
//! later read them directly), so the wrapper is a 22-byte header and the
|
||||||
|
//! file's bytes, and a generated binary stays out of the tree.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let png = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../ui/dr-ui/ui/app-icon.png");
|
||||||
|
println!("cargo:rerun-if-changed={}", png.display());
|
||||||
|
let bytes = std::fs::read(&png).expect("read app-icon.png");
|
||||||
|
let ico = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("darkroom.ico");
|
||||||
|
write_png_ico(&ico, &bytes, 256).expect("write darkroom.ico");
|
||||||
|
|
||||||
|
let mut res = winresource::WindowsResource::new();
|
||||||
|
res.set_icon(ico.to_str().unwrap());
|
||||||
|
res.set("ProductName", "DarkRoom");
|
||||||
|
res.set("FileDescription", "DarkRoom");
|
||||||
|
res.set("LegalCopyright", "GPL-3.0-or-later");
|
||||||
|
// Cross-compiling: `winresource` looks for a `windres` for the target and
|
||||||
|
// the Windows image names it explicitly, for the same reason the Android
|
||||||
|
// image names its linkers.
|
||||||
|
if let Ok(windres) = std::env::var("WINDRES") {
|
||||||
|
res.set_windres_path(&windres);
|
||||||
|
}
|
||||||
|
res.compile().expect("compile the Windows resource block");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One PNG image as an `.ico`. `edge` is the PNG's width and height; 256 is
|
||||||
|
/// written as 0 per the format.
|
||||||
|
fn write_png_ico(path: &std::path::Path, png: &[u8], edge: u32) -> std::io::Result<()> {
|
||||||
|
let mut f = std::fs::File::create(path)?;
|
||||||
|
let dim = if edge >= 256 { 0u8 } else { edge as u8 };
|
||||||
|
// ICONDIR: reserved, type 1 (icon), one image.
|
||||||
|
f.write_all(&[0, 0, 1, 0, 1, 0])?;
|
||||||
|
// ICONDIRENTRY: width, height, palette 0, reserved, planes 1, bpp 32,
|
||||||
|
// byte length, offset (6 + 16).
|
||||||
|
f.write_all(&[dim, dim, 0, 0, 1, 0, 32, 0])?;
|
||||||
|
f.write_all(&(png.len() as u32).to_le_bytes())?;
|
||||||
|
f.write_all(&22u32.to_le_bytes())?;
|
||||||
|
f.write_all(png)
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
//! DarkRoom desktop entry point.
|
//! DarkRoom desktop entry point.
|
||||||
//!
|
//!
|
||||||
//! darkroom-desktop <file-or-directory>...
|
//! darkroom-desktop <file-or-directory>...
|
||||||
|
//! darkroom-desktop --version
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-WIN-2
|
||||||
|
// A GUI-subsystem executable, or Windows opens a console window behind the
|
||||||
|
// application for the life of the process. Release only: the console is where
|
||||||
|
// the log goes when there is no file, and a debug build is run from one.
|
||||||
|
// `--version` still prints under this — stdout is simply not attached when
|
||||||
|
// launched from Explorer, which is not where anyone asks for a version.
|
||||||
|
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use dr_plat::diagnostics::Installed;
|
use dr_plat::diagnostics::Installed;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
|
// TRACES: FR-PLAT-WIN-3
|
||||||
|
// Before the logger, the crash hook and everything else: this exists so a
|
||||||
|
// build made on a machine that cannot run the application — the Linux CI
|
||||||
|
// producing the Windows binary, checked under Wine — has an exit that
|
||||||
|
// proves the executable starts without opening a window or touching the
|
||||||
|
// user's directories (docs/windows.md §6).
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||||
|
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Built rather than `init`ed, so the same logger can be handed to the
|
// Built rather than `init`ed, so the same logger can be handed to the
|
||||||
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
|
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
|
||||||
// and every record it accepts is also appended to the on-disk log
|
// and every record it accepts is also appended to the on-disk log
|
||||||
|
|||||||
@@ -301,13 +301,7 @@ fn like_prefix(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn label_code(l: ColourLabel) -> i64 {
|
fn label_code(l: ColourLabel) -> i64 {
|
||||||
match l {
|
crate::rating::label_code(l)
|
||||||
ColourLabel::Red => 1,
|
|
||||||
ColourLabel::Yellow => 2,
|
|
||||||
ColourLabel::Green => 3,
|
|
||||||
ColourLabel::Blue => 4,
|
|
||||||
ColourLabel::Purple => 5,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flag_code(f: FlagState) -> i64 {
|
fn flag_code(f: FlagState) -> i64 {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
use rusqlite::{Connection, OptionalExtension};
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
|
|
||||||
use dr_types::{FlagState, ImageId};
|
use dr_types::{ColourLabel, FlagState, ImageId};
|
||||||
|
|
||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
@@ -275,6 +275,35 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
|||||||
/// version pass was interrupted between the image insert and the commit.
|
/// version pass was interrupted between the image insert and the commit.
|
||||||
/// Failing a rating because of either would be the wrong answer — the user
|
/// Failing a rating because of either would be the wrong answer — the user
|
||||||
/// pressed a key and expects a star.
|
/// pressed a key and expects a star.
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// How `versions.label` encodes a colour label, and back.
|
||||||
|
///
|
||||||
|
/// One place for both directions, so a label written by the XMP pull and a
|
||||||
|
/// label queried by the selector cannot drift apart: the query used to hold
|
||||||
|
/// its own copy of the forward mapping and nothing held the reverse.
|
||||||
|
pub fn label_code(l: ColourLabel) -> i64 {
|
||||||
|
match l {
|
||||||
|
ColourLabel::Red => 1,
|
||||||
|
ColourLabel::Yellow => 2,
|
||||||
|
ColourLabel::Green => 3,
|
||||||
|
ColourLabel::Blue => 4,
|
||||||
|
ColourLabel::Purple => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour a `versions.label` value names, or `None` for NULL and for a
|
||||||
|
/// code this build does not know.
|
||||||
|
pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
||||||
|
Some(match code? {
|
||||||
|
1 => ColourLabel::Red,
|
||||||
|
2 => ColourLabel::Yellow,
|
||||||
|
3 => ColourLabel::Green,
|
||||||
|
4 => ColourLabel::Blue,
|
||||||
|
5 => ColourLabel::Purple,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||||
let existing: Option<i64> = conn
|
let existing: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
|
|||||||
@@ -198,6 +198,54 @@ pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(),
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a catalog may go without a backup before the next opportunity
|
||||||
|
/// takes one.
|
||||||
|
///
|
||||||
|
/// A day. The catalog is an index, so what a backup protects is the day's
|
||||||
|
/// worth of collection and people edits the sidecars do not hold — and a
|
||||||
|
/// second copy of a 130 MB file per launch would be a cost with nothing to
|
||||||
|
/// show for it when the user launches four times in an afternoon.
|
||||||
|
pub const BACKUP_EVERY: i64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Whether [`BACKUP_EVERY`] has passed since the newest backup, or there is
|
||||||
|
/// none.
|
||||||
|
///
|
||||||
|
/// Read from the filenames, like [`backups`], so a restored or copied backup
|
||||||
|
/// directory answers the same way it did on the machine it came from.
|
||||||
|
pub fn backup_due(catalog: &Path) -> bool {
|
||||||
|
match backups(catalog).first() {
|
||||||
|
Some(newest) => now() - newest.taken_at >= BACKUP_EVERY,
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-R2
|
||||||
|
/// Take the scheduled backup, if one is due. Returns the file written, or
|
||||||
|
/// `None` when the newest is recent enough.
|
||||||
|
///
|
||||||
|
/// The scheduled half of NFR-R2 — the migration half is
|
||||||
|
/// [`backup_before_migration`]. "On a schedule" for an application that runs
|
||||||
|
/// when the user opens it means "at the next chance after a day has passed",
|
||||||
|
/// and the chance the caller picks is the end of a library sweep: the
|
||||||
|
/// catalog is quiet, the work is already off the UI thread, and it is the
|
||||||
|
/// moment a day's edits have just been consolidated.
|
||||||
|
///
|
||||||
|
/// A brand-new catalog with no images is not backed up: there is nothing in
|
||||||
|
/// it yet that a rescan would not rebuild, and the first backup would only be
|
||||||
|
/// a copy of an empty schema.
|
||||||
|
pub fn backup_if_due(conn: &Connection, catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||||
|
if !backup_due(catalog) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let images: i64 = conn.query_row("SELECT count(*) FROM images", [], |r| r.get(0))?;
|
||||||
|
if images == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let path = backup(conn, catalog)?;
|
||||||
|
log::info!("scheduled backup of the catalog to {}", path.display());
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
/// The backups available for `catalog`, newest first.
|
/// The backups available for `catalog`, newest first.
|
||||||
///
|
///
|
||||||
/// Never fails: an unreadable or absent backup directory means there are no
|
/// Never fails: an unreadable or absent backup directory means there are no
|
||||||
@@ -386,6 +434,49 @@ mod tests {
|
|||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_scheduled_backup_is_taken_once_a_day_and_not_more() {
|
||||||
|
let dir = tempdir("scheduled");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
fixture(&path, 3);
|
||||||
|
let cat = Catalog::open(&path).unwrap();
|
||||||
|
|
||||||
|
// Nothing yet: due.
|
||||||
|
assert!(backup_due(&path));
|
||||||
|
let first = backup_if_due(cat.connection(), &path).unwrap();
|
||||||
|
assert!(first.is_some(), "the first opportunity takes one");
|
||||||
|
|
||||||
|
// Taken just now: not due, and a second call does nothing.
|
||||||
|
assert!(!backup_due(&path));
|
||||||
|
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||||
|
assert_eq!(backups(&path).len(), 1);
|
||||||
|
|
||||||
|
// Age the one backup past the interval by renaming it, since the
|
||||||
|
// timestamp is read from the name. Now it is due again.
|
||||||
|
let old = first.unwrap();
|
||||||
|
let aged = old
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join(format!("catalog-{}.sqlite", now() - BACKUP_EVERY - 1));
|
||||||
|
std::fs::rename(&old, &aged).unwrap();
|
||||||
|
assert!(backup_due(&path));
|
||||||
|
assert!(backup_if_due(cat.connection(), &path).unwrap().is_some());
|
||||||
|
assert_eq!(backups(&path).len(), 2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_catalog_is_not_worth_backing_up() {
|
||||||
|
let dir = tempdir("empty");
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
let cat = Catalog::open(&path).unwrap();
|
||||||
|
assert!(backup_due(&path), "due in principle");
|
||||||
|
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||||
|
assert!(backups(&path).is_empty());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
/// A catalog on disk with enough rows to span several pages, closed.
|
/// A catalog on disk with enough rows to span several pages, closed.
|
||||||
///
|
///
|
||||||
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// Schema version this build writes and understands.
|
||||||
pub const SCHEMA_VERSION: i64 = 14;
|
pub const SCHEMA_VERSION: i64 = 15;
|
||||||
|
|
||||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -136,6 +136,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from < 15 {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute_batch(V15)?;
|
||||||
|
tx.pragma_update(None, "user_version", 15)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(from)
|
Ok(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,6 +649,36 @@ DELETE FROM face_index
|
|||||||
AND f.model_id = face_index.model_id);
|
AND f.model_id = face_index.model_id);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const V15: &str = r#"
|
||||||
|
-- TRACES: FR-CAT-13
|
||||||
|
-- Where a standard XMP sidecar and the catalog disagree.
|
||||||
|
--
|
||||||
|
-- An `.xmp` beside a photograph is read on the same pull as DarkRoom's own
|
||||||
|
-- sidecar, and reconciled field by field (`dr_xmp::reconcile`): keywords
|
||||||
|
-- union, and a rating, label or caption is taken only where the catalog holds
|
||||||
|
-- none. That rule is the safe one and it is not always the right one -- a
|
||||||
|
-- rating changed in Lightroom after it was changed here is a genuine
|
||||||
|
-- disagreement, and a standard XMP carries no revision to settle it by. So
|
||||||
|
-- the disagreement is written here instead of being resolved, and the
|
||||||
|
-- requirement's "a metadata reload offered" is a row in this table with a
|
||||||
|
-- button in front of it: the reload re-reads the file with the sidecar
|
||||||
|
-- winning, and deletes the row.
|
||||||
|
--
|
||||||
|
-- Keyed on the sidecar's path like `sidecars` is, and for the same reason: a
|
||||||
|
-- path is what the scan reports, what a fetch addresses, and what the ETag
|
||||||
|
-- that noticed the change belongs to. `fields` is the disagreeing fields as
|
||||||
|
-- `dr_xmp` names them, space-separated, for the line the settings page shows.
|
||||||
|
--
|
||||||
|
-- Rebuildable: the next pull that sees a changed ETag writes the row again.
|
||||||
|
CREATE TABLE IF NOT EXISTS xmp_conflicts (
|
||||||
|
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
fields TEXT NOT NULL,
|
||||||
|
seen_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY(root_id, path)
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
const V9: &str = r#"
|
const V9: &str = r#"
|
||||||
-- TRACES: FR-CULL-8
|
-- TRACES: FR-CULL-8
|
||||||
-- A record that face detection has *run* on an image, distinct from what it
|
-- A record that face detection has *run* on an image, distinct from what it
|
||||||
|
|||||||
@@ -54,9 +54,31 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
|||||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||||
let out = copy_to(conn, dest)?;
|
let out = copy_to(conn, dest)?;
|
||||||
strip_face_crops(&out)?;
|
strip_face_crops(&out)?;
|
||||||
|
verify_snapshot(&out)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-R2
|
||||||
|
/// Refuse to hand over a snapshot that will not pass `quick_check`.
|
||||||
|
///
|
||||||
|
/// The upload is the copy every other device merges from, and a damaged one
|
||||||
|
/// costs far more than the check: each device downloads it, fails, and — for
|
||||||
|
/// a week, once — declines to push over it. `quick_check` reads every page
|
||||||
|
/// but skips index verification, which is the affordable version of "is this
|
||||||
|
/// a database" on a 40 MB file that has just been written and is still in the
|
||||||
|
/// page cache. A failure here is [`CatalogError::Corrupt`], the same thing a
|
||||||
|
/// receiving device would have said, so the sync reports it the same way.
|
||||||
|
fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||||
|
let verdict: String = snapshot.query_row("PRAGMA quick_check", [], |r| r.get(0))?;
|
||||||
|
if verdict == "ok" {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CatalogError::Corrupt {
|
||||||
|
detail: format!("the snapshot for upload failed quick_check: {verdict}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||||
/// connection to the copy.
|
/// connection to the copy.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -332,6 +332,20 @@ impl GpuContext {
|
|||||||
pub fn backend(&self) -> wgpu::Backend {
|
pub fn backend(&self) -> wgpu::Backend {
|
||||||
self.adapter_info.backend
|
self.adapter_info.backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-OPS-1
|
||||||
|
/// The driver, as the adapter reported it, for a diagnostics bundle.
|
||||||
|
/// Name and version in one string because wgpu splits them by backend
|
||||||
|
/// and neither half means much without the other.
|
||||||
|
pub fn driver(&self) -> String {
|
||||||
|
let info = &self.adapter_info;
|
||||||
|
match (info.driver.is_empty(), info.driver_info.is_empty()) {
|
||||||
|
(true, true) => "unknown driver".to_string(),
|
||||||
|
(false, true) => info.driver.clone(),
|
||||||
|
(true, false) => info.driver_info.clone(),
|
||||||
|
(false, false) => format!("{} {}", info.driver, info.driver_info),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
|
|||||||
+40
-2
@@ -758,12 +758,24 @@ impl MaskPass {
|
|||||||
// is done where it is read back rather than where it is drawn —
|
// is done where it is read back rather than where it is drawn —
|
||||||
// a brush deposits dabs and cannot know what the rest of the
|
// a brush deposits dabs and cannot know what the rest of the
|
||||||
// frame is. See `fs_combine`.
|
// frame is. See `fs_combine`.
|
||||||
let direct = layer.parts().len() == 1 && !layer.base().invert;
|
// TRACES: FR-DEV-19a
|
||||||
|
// The shown parts, not the parts: a hidden one is skipped here
|
||||||
|
// and nowhere else, and the first *shown* part is the one that
|
||||||
|
// opens the fold. Which can leave nothing — a revealed layer with
|
||||||
|
// every part hidden — and that clears the slice rather than
|
||||||
|
// leaving whatever the last rasterisation put there to be read
|
||||||
|
// back as this mask.
|
||||||
|
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
|
||||||
|
if shown.is_empty() {
|
||||||
|
self.clear_slice(&mut encoder, slot as u32);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let direct = shown.len() == 1 && !shown[0].invert;
|
||||||
if !direct {
|
if !direct {
|
||||||
self.ensure_scratch(width, height)?;
|
self.ensure_scratch(width, height)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (index, part) in layer.parts().iter().enumerate() {
|
for (index, part) in shown.iter().copied().enumerate() {
|
||||||
let base = index == 0;
|
let base = index == 0;
|
||||||
let field = match (&part.source, labels) {
|
let field = match (&part.source, labels) {
|
||||||
(MaskSource::Regions { .. }, None) => {
|
(MaskSource::Regions { .. }, None) => {
|
||||||
@@ -1369,6 +1381,32 @@ impl MaskPass {
|
|||||||
pass.draw(0..3, 0..1);
|
pass.draw(0..3, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Leave a layer's slice covering nothing.
|
||||||
|
///
|
||||||
|
/// A pass that clears and draws nothing, for the one case where a layer
|
||||||
|
/// reaches the array with no part to draw: every part hidden while the
|
||||||
|
/// layer is being revealed. The slice has to be written, because the
|
||||||
|
/// shader reads it whatever this function did.
|
||||||
|
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
|
||||||
|
let target = self.slice_view(slot);
|
||||||
|
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("mask-clear-pass"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &target,
|
||||||
|
depth_slice: None,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
multiview_mask: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The texture a part is drawn in before it is joined.
|
/// The texture a part is drawn in before it is joined.
|
||||||
///
|
///
|
||||||
/// Allocated on the first mask that has more than one part and kept at the
|
/// Allocated on the first mask that has more than one part and kept at the
|
||||||
|
|||||||
@@ -1034,6 +1034,8 @@ impl EditGraph {
|
|||||||
colour = hash_bytes(colour, part.join.name().as_bytes());
|
colour = hash_bytes(colour, part.join.name().as_bytes());
|
||||||
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
|
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
|
||||||
colour = mix(colour, u64::from(part.invert));
|
colour = mix(colour, u64::from(part.invert));
|
||||||
|
// Hiding a part changes the fold as surely as removing it.
|
||||||
|
colour = mix(colour, u64::from(part.hidden));
|
||||||
colour = hash_bytes(colour, part.falloff.name().as_bytes());
|
colour = hash_bytes(colour, part.falloff.name().as_bytes());
|
||||||
for v in [part.feather, part.morph_radius] {
|
for v in [part.feather, part.morph_radius] {
|
||||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||||
|
|||||||
@@ -984,6 +984,20 @@ pub struct MaskPart {
|
|||||||
/// subtracted gradient keeps everything to one side of a line, where
|
/// subtracted gradient keeps everything to one side of a line, where
|
||||||
/// inverting the layer keeps everything the layer did not select.
|
/// inverting the layer keeps everything the layer did not select.
|
||||||
pub invert: bool,
|
pub invert: bool,
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// Left out of the build without being deleted.
|
||||||
|
///
|
||||||
|
/// The A/B a *part* wants is different from the layer's: the question is
|
||||||
|
/// not "what does this adjustment do" but "what did this correction do to
|
||||||
|
/// the selection" — whether the stroke that was meant to fill in a
|
||||||
|
/// shoulder did, or the subtracted gradient took the sky it was aimed at
|
||||||
|
/// and nothing else. Removing the part answers that and loses it; this
|
||||||
|
/// answers it and keeps it.
|
||||||
|
///
|
||||||
|
/// Hidden parts are skipped where the mask is built, so a hidden base
|
||||||
|
/// leaves the first shown part to open the fold — and a mask whose every
|
||||||
|
/// adding part is hidden covers nothing, exactly as if they were gone.
|
||||||
|
pub hidden: bool,
|
||||||
|
|
||||||
/// Half-width of the edge transition, as a fraction of the frame's
|
/// Half-width of the edge transition, as a fraction of the frame's
|
||||||
/// **shorter edge**.
|
/// **shorter edge**.
|
||||||
@@ -1066,6 +1080,7 @@ impl MaskPart {
|
|||||||
join,
|
join,
|
||||||
source,
|
source,
|
||||||
invert: false,
|
invert: false,
|
||||||
|
hidden: false,
|
||||||
// A small default rather than zero. A watershed boundary is exact
|
// A small default rather than zero. A watershed boundary is exact
|
||||||
// to the pixel, and an adjustment that stops dead on one looks
|
// to the pixel, and an adjustment that stops dead on one looks
|
||||||
// pasted on — the first thing anyone would reach for, so it is
|
// pasted on — the first thing anyone would reach for, so it is
|
||||||
@@ -1280,6 +1295,7 @@ impl PartialEq for MaskPart {
|
|||||||
&& self.join == other.join
|
&& self.join == other.join
|
||||||
&& self.source == other.source
|
&& self.source == other.source
|
||||||
&& self.invert == other.invert
|
&& self.invert == other.invert
|
||||||
|
&& self.hidden == other.hidden
|
||||||
&& self.feather == other.feather
|
&& self.feather == other.feather
|
||||||
&& self.falloff == other.falloff
|
&& self.falloff == other.falloff
|
||||||
&& self.morphology == other.morphology
|
&& self.morphology == other.morphology
|
||||||
@@ -1555,12 +1571,23 @@ impl MaskLayer {
|
|||||||
/// whose other parts all subtract covers nothing however many of them
|
/// whose other parts all subtract covers nothing however many of them
|
||||||
/// there are, and rasterising it would cost a slice to draw empty.
|
/// there are, and rasterising it would cost a slice to draw empty.
|
||||||
fn covers(&self) -> bool {
|
fn covers(&self) -> bool {
|
||||||
self.parts
|
// A hidden part is not in the build, whichever way it joins — and a
|
||||||
.iter()
|
// hidden base hands its role to the first part that is shown, which
|
||||||
|
// is why "adds" is asked of the shown parts rather than of index 0.
|
||||||
|
self.shown_parts()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
|
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// The parts that take part in the build, in order — every part that is
|
||||||
|
/// not [`MaskPart::hidden`]. The first of these opens the fold whatever
|
||||||
|
/// its index in [`Self::parts`], so a renderer walks this rather than
|
||||||
|
/// filtering for itself and getting the "first" wrong.
|
||||||
|
pub fn shown_parts(&self) -> impl Iterator<Item = &MaskPart> {
|
||||||
|
self.parts.iter().filter(|p| !p.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
/// The operations in this layer's chain that reach the shader.
|
/// The operations in this layer's chain that reach the shader.
|
||||||
///
|
///
|
||||||
/// Neighbourhood operations are excluded, and not as an oversight. A
|
/// Neighbourhood operations are excluded, and not as an oversight. A
|
||||||
@@ -2883,6 +2910,36 @@ mod tests {
|
|||||||
// Parts
|
// Parts
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// A hidden part is out of the build and nothing else about it moves: it
|
||||||
|
/// is still there to be put back, and the parts that remain shown are
|
||||||
|
/// what the mask is made of — including which of them is first.
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_part_leaves_the_build_and_stays_in_the_layer() {
|
||||||
|
let mut layer = MaskLayer::new("m1", MaskSource::brush());
|
||||||
|
layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights()));
|
||||||
|
layer.push_part(MaskPart::painted("p3", Join::Subtract));
|
||||||
|
layer.set_param("exposure", ParamId("exposure"), 0.5);
|
||||||
|
|
||||||
|
assert_eq!(layer.shown_parts().count(), 3);
|
||||||
|
assert!(layer.is_active(), "the range adds, so the mask covers");
|
||||||
|
|
||||||
|
layer.part_mut(1).expect("p2").hidden = true;
|
||||||
|
assert_eq!(layer.parts().len(), 3, "hidden is not removed");
|
||||||
|
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
|
||||||
|
assert_eq!(shown, ["p1", "p3"]);
|
||||||
|
assert!(
|
||||||
|
!layer.is_active(),
|
||||||
|
"with the range out, an unpainted base and a subtraction cover nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
layer.part_mut(1).expect("p2").hidden = false;
|
||||||
|
layer.base_mut().hidden = true;
|
||||||
|
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
|
||||||
|
assert_eq!(shown, ["p2", "p3"], "the range now opens the fold");
|
||||||
|
assert!(layer.is_active(), "and it still covers the picture");
|
||||||
|
}
|
||||||
|
|
||||||
/// The invariant everything else leans on: a layer always has a selection
|
/// The invariant everything else leans on: a layer always has a selection
|
||||||
/// to be. Removing the base is removing the layer, and the panel must not
|
/// to be. Removing the base is removing the layer, and the panel must not
|
||||||
/// be able to reach a state where a mask exists with nothing in it.
|
/// be able to reach a state where a mask exists with nothing in it.
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ pub struct Version {
|
|||||||
pub uuid: String,
|
pub uuid: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The version this is a named snapshot of, if it is one.
|
||||||
|
///
|
||||||
|
/// A snapshot *is* an edit state, which is exactly what a version stores,
|
||||||
|
/// so it is stored as one: the parameters, the masks and their parts, the
|
||||||
|
/// repairs and the film all arrive through the blocks that already carry
|
||||||
|
/// them, and a merge keys on the uuid as it does for any other version.
|
||||||
|
/// What sets a snapshot apart is only this pointer — it belongs to
|
||||||
|
/// another version's history rather than standing beside it as a variant
|
||||||
|
/// (FR-CAT-12), so a reader listing what a photograph *is* skips it, and
|
||||||
|
/// a reader listing what one edit *was* finds it here.
|
||||||
|
///
|
||||||
|
/// Written as `snapshot-of`. A build that predates the key reads the
|
||||||
|
/// block as an ordinary named version and keeps it, which is the right
|
||||||
|
/// failure: nothing is lost, and the newer build finds it as a snapshot
|
||||||
|
/// again.
|
||||||
|
pub snapshot_of: Option<String>,
|
||||||
/// Monotonic per-edit counter (FR-NC-8).
|
/// Monotonic per-edit counter (FR-NC-8).
|
||||||
///
|
///
|
||||||
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
|
/// The primary merge discriminator, ahead of [`Self::modified`]: a device
|
||||||
@@ -222,6 +239,7 @@ impl Version {
|
|||||||
uuid: uuid.into(),
|
uuid: uuid.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
is_default: false,
|
is_default: false,
|
||||||
|
snapshot_of: None,
|
||||||
revision: 1,
|
revision: 1,
|
||||||
device: String::new(),
|
device: String::new(),
|
||||||
modified: 0,
|
modified: 0,
|
||||||
@@ -267,6 +285,12 @@ impl Version {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// Whether this version is a named snapshot of another one.
|
||||||
|
pub fn is_snapshot(&self) -> bool {
|
||||||
|
self.snapshot_of.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Record `graph` into this version, bumping the revision.
|
/// Record `graph` into this version, bumping the revision.
|
||||||
///
|
///
|
||||||
/// The revision bump is what makes this the write path rather than a
|
/// The revision bump is what makes this the write path rather than a
|
||||||
@@ -645,7 +669,52 @@ impl Sidecar {
|
|||||||
.values()
|
.values()
|
||||||
.filter(|v| v.is_default)
|
.filter(|v| v.is_default)
|
||||||
.max_by_key(|v| (v.revision, v.modified))
|
.max_by_key(|v| (v.revision, v.modified))
|
||||||
.or_else(|| self.versions.values().next())
|
// Never a snapshot: a file with no default and a snapshot in it
|
||||||
|
// is a file whose edit is *missing*, and answering with a saved
|
||||||
|
// state of it would open the photograph at a moment the
|
||||||
|
// photographer deliberately stepped away from.
|
||||||
|
.or_else(|| self.versions.values().find(|v| !v.is_snapshot()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The named snapshots of one version, oldest first.
|
||||||
|
///
|
||||||
|
/// Taken time is `modified`, so the order is the order they were taken in
|
||||||
|
/// whichever device took them; the uuid breaks a tie so the list reads
|
||||||
|
/// the same on every device.
|
||||||
|
pub fn snapshots_of(&self, uuid: &str) -> Vec<&Version> {
|
||||||
|
let mut out: Vec<&Version> = self
|
||||||
|
.versions
|
||||||
|
.values()
|
||||||
|
.filter(|v| v.snapshot_of.as_deref() == Some(uuid))
|
||||||
|
.collect();
|
||||||
|
out.sort_by(|a, b| (a.modified, &a.uuid).cmp(&(b.modified, &b.uuid)));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5 | FR-NC-9
|
||||||
|
/// Write a session's snapshots of `uuid` into the file.
|
||||||
|
///
|
||||||
|
/// `removed` are the ones the session deleted, taken out by id;
|
||||||
|
/// `snapshots` are the ones it holds, put in. A snapshot in the file that
|
||||||
|
/// is in neither — one another device took since this session opened
|
||||||
|
/// the photograph — is left standing, which is the same rule
|
||||||
|
/// [`Version::merge`] keeps for a version only one side has: never treat
|
||||||
|
/// "I did not see it" as "I removed it".
|
||||||
|
///
|
||||||
|
/// Each snapshot is re-pointed at `uuid` on the way in, because the
|
||||||
|
/// default version may have been fused onto a canonical identity since
|
||||||
|
/// the snapshot was taken, and a snapshot of a uuid nothing carries is
|
||||||
|
/// a snapshot of nothing.
|
||||||
|
pub fn replace_snapshots(&mut self, uuid: &str, snapshots: Vec<Version>, removed: &[String]) {
|
||||||
|
for id in removed {
|
||||||
|
self.versions.remove(id);
|
||||||
|
}
|
||||||
|
for mut snapshot in snapshots {
|
||||||
|
snapshot.snapshot_of = Some(uuid.to_string());
|
||||||
|
snapshot.is_default = false;
|
||||||
|
self.put(snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-8 | FR-NC-9
|
/// TRACES: FR-NC-8 | FR-NC-9
|
||||||
@@ -763,6 +832,10 @@ impl Sidecar {
|
|||||||
if v.is_default {
|
if v.is_default {
|
||||||
let _ = writeln!(out, "default = 1");
|
let _ = writeln!(out, "default = 1");
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
if let Some(of) = &v.snapshot_of {
|
||||||
|
let _ = writeln!(out, "snapshot-of = {of}");
|
||||||
|
}
|
||||||
let _ = writeln!(out, "revision = {}", v.revision);
|
let _ = writeln!(out, "revision = {}", v.revision);
|
||||||
if !v.device.is_empty() {
|
if !v.device.is_empty() {
|
||||||
let _ = writeln!(out, "device = {}", v.device);
|
let _ = writeln!(out, "device = {}", v.device);
|
||||||
@@ -942,6 +1015,8 @@ impl Sidecar {
|
|||||||
match key {
|
match key {
|
||||||
"name" => version.name = value.to_string(),
|
"name" => version.name = value.to_string(),
|
||||||
"default" => version.is_default = value != "0",
|
"default" => version.is_default = value != "0",
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
"snapshot-of" => version.snapshot_of = Some(value.to_string()),
|
||||||
"revision" => version.revision = value.parse().unwrap_or(0),
|
"revision" => version.revision = value.parse().unwrap_or(0),
|
||||||
"device" => version.device = value.to_string(),
|
"device" => version.device = value.to_string(),
|
||||||
"modified" => version.modified = value.parse().unwrap_or(0),
|
"modified" => version.modified = value.parse().unwrap_or(0),
|
||||||
@@ -1121,6 +1196,13 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
|||||||
if !layer.enabled {
|
if !layer.enabled {
|
||||||
let _ = writeln!(out, "enabled = 0");
|
let _ = writeln!(out, "enabled = 0");
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-19a
|
||||||
|
// The base part's own switch, under a different word from the layer's:
|
||||||
|
// `enabled` in this block has always meant the layer, and a part that is
|
||||||
|
// out of the build is `hidden` wherever it is written, base or not.
|
||||||
|
if layer.base().hidden {
|
||||||
|
let _ = writeln!(out, "hidden = 1");
|
||||||
|
}
|
||||||
write_shaping(out, layer.base());
|
write_shaping(out, layer.base());
|
||||||
for (op, param, value) in layer.params() {
|
for (op, param, value) in layer.params() {
|
||||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
||||||
@@ -1148,6 +1230,9 @@ fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
|
|||||||
if part.invert {
|
if part.invert {
|
||||||
let _ = writeln!(out, "invert = 1");
|
let _ = writeln!(out, "invert = 1");
|
||||||
}
|
}
|
||||||
|
if part.hidden {
|
||||||
|
let _ = writeln!(out, "hidden = 1");
|
||||||
|
}
|
||||||
write_shaping(out, part);
|
write_shaping(out, part);
|
||||||
write_coverage(out, part);
|
write_coverage(out, part);
|
||||||
}
|
}
|
||||||
@@ -1411,6 +1496,7 @@ struct PartialPart {
|
|||||||
/// A colour range's arc: centre and half-width, in turns.
|
/// A colour range's arc: centre and half-width, in turns.
|
||||||
hue: (f32, f32),
|
hue: (f32, f32),
|
||||||
invert: bool,
|
invert: bool,
|
||||||
|
hidden: bool,
|
||||||
/// The *part's* edge transition, distinct from the radial source's own
|
/// The *part's* edge transition, distinct from the radial source's own
|
||||||
/// `feather` above — different quantity, different units, different key.
|
/// `feather` above — different quantity, different units, different key.
|
||||||
edge_feather: f32,
|
edge_feather: f32,
|
||||||
@@ -1446,6 +1532,7 @@ impl PartialPart {
|
|||||||
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
|
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
|
||||||
hue: (0.06, 0.05),
|
hue: (0.06, 0.05),
|
||||||
invert: false,
|
invert: false,
|
||||||
|
hidden: false,
|
||||||
edge_feather: DEFAULT_FEATHER,
|
edge_feather: DEFAULT_FEATHER,
|
||||||
falloff: Falloff::default(),
|
falloff: Falloff::default(),
|
||||||
morphology: Morphology::default(),
|
morphology: Morphology::default(),
|
||||||
@@ -1510,6 +1597,9 @@ impl PartialPart {
|
|||||||
// and the file's line order is the order they were painted in.
|
// and the file's line order is the order they were painted in.
|
||||||
"stroke" => self.strokes.extend(parse_stroke(value)),
|
"stroke" => self.strokes.extend(parse_stroke(value)),
|
||||||
"invert" => self.invert = value != "0",
|
"invert" => self.invert = value != "0",
|
||||||
|
// Absent means shown, so a file from before the switch existed
|
||||||
|
// reads back with every part in the build, as it was written.
|
||||||
|
"hidden" => self.hidden = value != "0",
|
||||||
// Clamped, not trusted: a feather wider than the frame is not a
|
// Clamped, not trusted: a feather wider than the frame is not a
|
||||||
// mask, and a negative one is a distance field read backwards.
|
// mask, and a negative one is a distance field read backwards.
|
||||||
"edge-feather" => {
|
"edge-feather" => {
|
||||||
@@ -1611,6 +1701,7 @@ impl PartialPart {
|
|||||||
|
|
||||||
let mut part = MaskPart::new(self.id, self.join, source);
|
let mut part = MaskPart::new(self.id, self.join, source);
|
||||||
part.invert = self.invert;
|
part.invert = self.invert;
|
||||||
|
part.hidden = self.hidden;
|
||||||
part.feather = self.edge_feather;
|
part.feather = self.edge_feather;
|
||||||
part.falloff = self.falloff;
|
part.falloff = self.falloff;
|
||||||
part.morphology = self.morphology;
|
part.morphology = self.morphology;
|
||||||
@@ -2331,6 +2422,89 @@ mod tests {
|
|||||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// A snapshot is a version with a pointer, and the pointer survives the
|
||||||
|
/// file: it comes back as a snapshot of the edit it was taken from, with
|
||||||
|
/// the edit it stored, and the photograph still opens at its default.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_round_trips_as_a_snapshot_of_its_edit() {
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
let mut current = Version::from_graph("u1", "Default", &edited());
|
||||||
|
current.is_default = true;
|
||||||
|
sidecar.put(current);
|
||||||
|
|
||||||
|
let mut mono = EditGraph::default_chain();
|
||||||
|
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
|
||||||
|
let mut snapshot = Version::from_graph("snap-1", "Black and white", &mono);
|
||||||
|
snapshot.snapshot_of = Some("u1".into());
|
||||||
|
snapshot.modified = 7;
|
||||||
|
sidecar.put(snapshot);
|
||||||
|
|
||||||
|
let text = sidecar.to_text();
|
||||||
|
assert!(text.contains("snapshot-of = u1"), "{text}");
|
||||||
|
let parsed = Sidecar::parse(&text).expect("valid");
|
||||||
|
|
||||||
|
assert_eq!(parsed.default_version().expect("default").uuid, "u1");
|
||||||
|
let snapshots = parsed.snapshots_of("u1");
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert_eq!(snapshots[0].name, "Black and white");
|
||||||
|
assert!(snapshots[0].is_snapshot());
|
||||||
|
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
snapshots[0].apply(&mut g).expect_no_film();
|
||||||
|
assert_eq!(
|
||||||
|
g.param(saturation::ID, saturation::SATURATION),
|
||||||
|
Some(-100.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// A file whose only versions are snapshots has no edit to open, and
|
||||||
|
/// must not answer with one of the snapshots as if it were.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_is_never_the_default() {
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
let mut snapshot = Version::from_graph("snap-1", "Earlier", &edited());
|
||||||
|
snapshot.snapshot_of = Some("gone".into());
|
||||||
|
sidecar.put(snapshot);
|
||||||
|
assert!(sidecar.default_version().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5 | FR-NC-9
|
||||||
|
/// Writing a session's snapshots back removes what it deleted and keeps
|
||||||
|
/// what it never saw — another device's snapshot is not this device's to
|
||||||
|
/// remove by not knowing about it.
|
||||||
|
#[test]
|
||||||
|
fn replacing_snapshots_removes_only_what_was_deleted() {
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
let mut current = Version::from_graph("u1", "Default", &edited());
|
||||||
|
current.is_default = true;
|
||||||
|
sidecar.put(current);
|
||||||
|
for id in ["mine-1", "mine-2", "theirs-1"] {
|
||||||
|
let mut v = Version::from_graph(id, id, &edited());
|
||||||
|
v.snapshot_of = Some("u1".into());
|
||||||
|
sidecar.put(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This session loaded mine-1 and mine-2, deleted mine-2, took mine-3.
|
||||||
|
let mut kept = Version::from_graph("mine-1", "mine-1", &edited());
|
||||||
|
kept.snapshot_of = Some("u1".into());
|
||||||
|
let taken = Version::from_graph("mine-3", "mine-3", &edited());
|
||||||
|
sidecar.replace_snapshots("u1", vec![kept, taken], &["mine-2".to_string()]);
|
||||||
|
|
||||||
|
let ids: Vec<&str> = sidecar
|
||||||
|
.snapshots_of("u1")
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.uuid.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ids, ["mine-1", "mine-3", "theirs-1"]);
|
||||||
|
assert_eq!(
|
||||||
|
sidecar.versions["mine-3"].snapshot_of.as_deref(),
|
||||||
|
Some("u1"),
|
||||||
|
"a snapshot taken without a pointer is pointed at the edit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_bumps_the_revision() {
|
fn update_bumps_the_revision() {
|
||||||
// FR-NC-9 resolves by revision; a write that did not bump it would
|
// FR-NC-9 resolves by revision; a write that did not bump it would
|
||||||
|
|||||||
@@ -1344,6 +1344,48 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// A part left out of the build comes back left out, and the file says so
|
||||||
|
/// under a word that cannot be confused with the layer's own switch.
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_part_stays_hidden_across_a_round_trip() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let mut layer = corrected("m1", Join::Subtract);
|
||||||
|
layer.part_mut(1).expect("the correction").hidden = true;
|
||||||
|
graph.masks_mut().push(layer);
|
||||||
|
|
||||||
|
let mut sidecar = Sidecar::new();
|
||||||
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||||
|
let text = sidecar.to_text();
|
||||||
|
assert!(text.contains("hidden = 1"), "{text}");
|
||||||
|
assert!(
|
||||||
|
!text.contains("enabled = 0"),
|
||||||
|
"a hidden part must not read as a disabled layer:\n{text}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let restored = round_trip(&graph);
|
||||||
|
let layer = &restored.masks().layers()[0];
|
||||||
|
assert!(layer.parts()[1].hidden, "the correction is still out");
|
||||||
|
assert!(!layer.parts()[0].hidden, "and the base is still in");
|
||||||
|
assert!(layer.enabled, "and the layer itself was never switched off");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// The base is a part like any other for this: it can be left out, and the
|
||||||
|
/// key lands in the mask block since that is where the base is written.
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_base_is_written_into_the_mask_block() {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let mut layer = corrected("m1", Join::Union);
|
||||||
|
layer.base_mut().hidden = true;
|
||||||
|
graph.masks_mut().push(layer);
|
||||||
|
|
||||||
|
let restored = round_trip(&graph);
|
||||||
|
let layer = &restored.masks().layers()[0];
|
||||||
|
assert!(layer.parts()[0].hidden, "the base came back hidden");
|
||||||
|
assert!(!layer.parts()[1].hidden, "and only the base");
|
||||||
|
}
|
||||||
|
|
||||||
/// A part carries its own edge, which is the whole reason it is a part rather
|
/// A part carries its own edge, which is the whole reason it is a part rather
|
||||||
/// than a second source on the layer: a model's soft coverage and a stroke
|
/// than a second source on the layer: a model's soft coverage and a stroke
|
||||||
/// painted where it stopped short want different boundaries.
|
/// painted where it stopped short want different boundaries.
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ use std::io::{BufRead, BufReader, Write};
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use dr_sync::RemoteError;
|
use dr_sync::RemoteError;
|
||||||
|
|
||||||
/// How long to wait for the client to acknowledge a command.
|
/// How long to wait for the client to acknowledge a command.
|
||||||
|
///
|
||||||
|
/// Only the socket path waits; on Windows the client speaks over a named pipe
|
||||||
|
/// this module does not yet open, so there is nothing to time.
|
||||||
|
#[cfg(unix)]
|
||||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
/// A connection to a running desktop client.
|
/// A connection to a running desktop client.
|
||||||
|
|||||||
@@ -121,16 +121,24 @@ impl NextcloudBackend {
|
|||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
) -> Result<Validator, RemoteError> {
|
) -> Result<Validator, RemoteError> {
|
||||||
let total = body.len() as u64;
|
let total = body.len() as u64;
|
||||||
// Named from the destination so a resumed or abandoned upload is
|
// TRACES: FR-NC-9
|
||||||
// identifiable, and so two uploads cannot collide in one directory.
|
// Named from the destination, so an abandoned upload is identifiable,
|
||||||
let token: String = path
|
// **and from a nonce, so no two uploads ever share a directory.**
|
||||||
.as_str()
|
//
|
||||||
.bytes()
|
// The name used to be the destination alone, on the reasoning that two
|
||||||
.map(|b| match b {
|
// *files* could then not collide. Two *devices* uploading the same file
|
||||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => (b as char).to_string(),
|
// could, and did: both wrote `00001`…`00009` into one directory, and
|
||||||
_ => "-".to_string(),
|
// whichever `MOVE`d first assembled a mix of the two — a catalog of
|
||||||
})
|
// exactly the right size whose pages came from two different
|
||||||
.collect();
|
// databases. SQLite called it malformed, every client then declined to
|
||||||
|
// overwrite it, and collections stopped syncing on all of them for a
|
||||||
|
// week. An upload that died on a phone's link left its chunks there
|
||||||
|
// for the next device to assemble in, by the same mechanism.
|
||||||
|
let token = format!(
|
||||||
|
"{}-{}",
|
||||||
|
sanitise_for_upload_dir(path.as_str()),
|
||||||
|
upload_nonce()
|
||||||
|
);
|
||||||
let dir = format!(
|
let dir = format!(
|
||||||
"{}/remote.php/dav/uploads/{}/{token}",
|
"{}/remote.php/dav/uploads/{}/{token}",
|
||||||
self.server, self.login
|
self.server, self.login
|
||||||
@@ -138,6 +146,27 @@ impl NextcloudBackend {
|
|||||||
|
|
||||||
self.mkcol_url(&dir).await?;
|
self.mkcol_url(&dir).await?;
|
||||||
|
|
||||||
|
// Whatever happens below, the directory does not outlive the attempt.
|
||||||
|
// With a unique name a leftover is only quota rather than corruption,
|
||||||
|
// but a phone that abandons uploads all day would still leave dozens
|
||||||
|
// of 5 MB chunks behind, and the server only sweeps them eventually.
|
||||||
|
let result = self.put_chunks_and_assemble(path, &dir, body, total).await;
|
||||||
|
if result.is_err() {
|
||||||
|
self.discard_upload_dir(&dir).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transfer half of [`put_chunked`](Self::put_chunked): the chunks,
|
||||||
|
/// then the `MOVE` that assembles them. Split out so that a failure at any
|
||||||
|
/// point returns to one place that cleans up.
|
||||||
|
async fn put_chunks_and_assemble(
|
||||||
|
&self,
|
||||||
|
path: &RemotePath,
|
||||||
|
dir: &str,
|
||||||
|
body: Vec<u8>,
|
||||||
|
total: u64,
|
||||||
|
) -> Result<Validator, RemoteError> {
|
||||||
// Chunks are numbered from 1 and must sort correctly as strings, which
|
// Chunks are numbered from 1 and must sort correctly as strings, which
|
||||||
// is why they are zero-padded rather than bare integers.
|
// is why they are zero-padded rather than bare integers.
|
||||||
let chunk_size = CHUNKS.min_chunk as usize;
|
let chunk_size = CHUNKS.min_chunk as usize;
|
||||||
@@ -191,6 +220,27 @@ impl NextcloudBackend {
|
|||||||
self.dir_validator(path).await
|
self.dir_validator(path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort `DELETE` of an upload directory whose transfer failed.
|
||||||
|
///
|
||||||
|
/// Errors are logged and dropped: this runs on the way out of a failure,
|
||||||
|
/// and the failure is what the caller needs to hear about. A connection
|
||||||
|
/// that has just died will refuse this too, and that is fine — the
|
||||||
|
/// server sweeps abandoned upload directories on its own; this only
|
||||||
|
/// spares it the wait when the link is still up.
|
||||||
|
async fn discard_upload_dir(&self, dir: &str) {
|
||||||
|
let attempt = self
|
||||||
|
.client
|
||||||
|
.delete(dir)
|
||||||
|
.basic_auth(&self.login, Some(&self.password))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match attempt {
|
||||||
|
Ok(resp) if resp.status().is_success() || resp.status() == 404 => {}
|
||||||
|
Ok(resp) => log::debug!("leaving abandoned upload {dir}: {}", resp.status()),
|
||||||
|
Err(e) => log::debug!("leaving abandoned upload {dir}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `MKCOL` at an absolute URL, treating "already there" as success.
|
/// `MKCOL` at an absolute URL, treating "already there" as success.
|
||||||
async fn mkcol_url(&self, url: &str) -> Result<(), RemoteError> {
|
async fn mkcol_url(&self, url: &str) -> Result<(), RemoteError> {
|
||||||
let resp = self
|
let resp = self
|
||||||
@@ -725,6 +775,37 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The destination path reduced to characters every WebDAV server accepts in
|
||||||
|
/// an upload directory name.
|
||||||
|
fn sanitise_for_upload_dir(path: &str) -> String {
|
||||||
|
path.bytes()
|
||||||
|
.map(|b| match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => (b as char).to_string(),
|
||||||
|
_ => "-".to_string(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token no other upload — on this device or any other — will produce.
|
||||||
|
///
|
||||||
|
/// Nanoseconds since the epoch, the process id, and a counter, mixed rather
|
||||||
|
/// than concatenated so the name stays short. Two devices would have to start
|
||||||
|
/// an upload in the same nanosecond from the same pid to collide, and the
|
||||||
|
/// counter separates two uploads this process starts in one tick. No
|
||||||
|
/// randomness crate is pulled in for this: unique is the requirement, not
|
||||||
|
/// unguessable.
|
||||||
|
fn upload_nonce() -> String {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let pid = u64::from(std::process::id());
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("{:016x}", nanos ^ (pid << 40) ^ n.rotate_left(20))
|
||||||
|
}
|
||||||
|
|
||||||
/// Translate an HTTP status into a typed error.
|
/// Translate an HTTP status into a typed error.
|
||||||
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
fn map_status(status: reqwest::StatusCode, what: &str) -> Result<(), RemoteError> {
|
||||||
match status.as_u16() {
|
match status.as_u16() {
|
||||||
@@ -765,6 +846,33 @@ fn encode_path(path: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn two_uploads_of_one_destination_never_share_a_directory() {
|
||||||
|
// The collision that assembled two devices' chunks into one file.
|
||||||
|
// Same path, back to back, same process: still two names.
|
||||||
|
let a = format!(
|
||||||
|
"{}-{}",
|
||||||
|
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
|
||||||
|
super::upload_nonce()
|
||||||
|
);
|
||||||
|
let b = format!(
|
||||||
|
"{}-{}",
|
||||||
|
super::sanitise_for_upload_dir("PhotosRaw/.darkroom-derived/catalog.sqlite"),
|
||||||
|
super::upload_nonce()
|
||||||
|
);
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert!(a.starts_with("PhotosRaw--darkroom-derived-catalog-sqlite-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_directory_names_are_plain() {
|
||||||
|
assert_eq!(
|
||||||
|
super::sanitise_for_upload_dir("Photos Raw/été/x.sqlite"),
|
||||||
|
"Photos-Raw---t---x-sqlite"
|
||||||
|
);
|
||||||
|
assert!(super::upload_nonce().bytes().all(|b| b.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn backend() -> NextcloudBackend {
|
fn backend() -> NextcloudBackend {
|
||||||
|
|||||||
@@ -67,15 +67,21 @@ pub fn set_data_dir(dir: PathBuf) {
|
|||||||
let _ = DATA_DIR.set(dir);
|
let _ = DATA_DIR.set(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The directory the platform entry point declared, if it declared one.
|
||||||
|
///
|
||||||
|
/// Android does; a desktop does not, and resolves through `dr_plat::dirs`
|
||||||
|
/// instead. Exposed so a caller wanting the *data* directory can honour the
|
||||||
|
/// same declaration without inheriting the config rule as its fallback.
|
||||||
|
pub fn declared_data_dir() -> Option<PathBuf> {
|
||||||
|
DATA_DIR.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
/// The directory configuration lives in.
|
/// The directory configuration lives in.
|
||||||
pub fn config_dir() -> PathBuf {
|
pub fn config_dir() -> PathBuf {
|
||||||
if let Some(d) = DATA_DIR.get() {
|
if let Some(d) = DATA_DIR.get() {
|
||||||
return d.clone();
|
return d.clone();
|
||||||
}
|
}
|
||||||
std::env::var_os("XDG_CONFIG_HOME")
|
dr_plat::base_dir(dr_plat::Base::Config)
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"))
|
|
||||||
.join("darkroom")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-12
|
/// TRACES: FR-NC-12
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ pub struct ScanProgress {
|
|||||||
/// on the whole edit format to recognise four characters in a filename.
|
/// on the whole edit format to recognise four characters in a filename.
|
||||||
pub const SIDECAR_EXTENSION: &str = "drsc";
|
pub const SIDECAR_EXTENSION: &str = "drsc";
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// Extension of a standard XMP sidecar — Lightroom's `IMG_0001.xmp`,
|
||||||
|
/// darktable's `IMG_0001.CR3.xmp`, and every other editor's.
|
||||||
|
///
|
||||||
|
/// Collected alongside DarkRoom's own for the same reason and at the same
|
||||||
|
/// cost: it is in the listing already, and it is the file the ratings and
|
||||||
|
/// keywords of a library edited elsewhere are in. Which images a given
|
||||||
|
/// `.xmp` describes is the catalog's question, since the two naming
|
||||||
|
/// conventions resolve differently and only the catalog knows the images.
|
||||||
|
pub const XMP_EXTENSION: &str = "xmp";
|
||||||
|
|
||||||
/// The result of a scan.
|
/// The result of a scan.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ScanResult {
|
pub struct ScanResult {
|
||||||
@@ -275,15 +286,18 @@ where
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a filename is a DarkRoom sidecar.
|
/// Whether a filename is a sidecar — DarkRoom's own, or a standard XMP one.
|
||||||
///
|
///
|
||||||
/// Case-insensitive on the extension alone. A server that upper-cased the
|
/// Case-insensitive on the extension alone. A server that upper-cased the
|
||||||
/// suffix — or a file copied through a filesystem that did — still describes a
|
/// suffix — or a file copied through a filesystem that did — still describes a
|
||||||
/// photograph, and failing to recognise it would silently lose the edit rather
|
/// photograph, and failing to recognise it would silently lose the edit rather
|
||||||
/// than fail visibly.
|
/// than fail visibly.
|
||||||
fn is_sidecar(name: &str) -> bool {
|
fn is_sidecar(name: &str) -> bool {
|
||||||
name.rsplit_once('.')
|
name.rsplit_once('.').is_some_and(|(stem, ext)| {
|
||||||
.is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case(SIDECAR_EXTENSION))
|
!stem.is_empty()
|
||||||
|
&& (ext.eq_ignore_ascii_case(SIDECAR_EXTENSION)
|
||||||
|
|| ext.eq_ignore_ascii_case(XMP_EXTENSION))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether pruning is worth attempting against this backend.
|
/// Whether pruning is worth attempting against this backend.
|
||||||
@@ -927,6 +941,10 @@ mod tests {
|
|||||||
// Upper-cased by a filesystem somewhere along the way; still a
|
// Upper-cased by a filesystem somewhere along the way; still a
|
||||||
// sidecar, and losing it would lose the edit silently.
|
// sidecar, and losing it would lose the edit silently.
|
||||||
file("Photos/2025/b.DRSC"),
|
file("Photos/2025/b.DRSC"),
|
||||||
|
// TRACES: FR-CAT-13
|
||||||
|
// And the standard kind, in both of its spellings.
|
||||||
|
file("Photos/2025/a.xmp"),
|
||||||
|
file("Photos/2025/b.jpg.xmp"),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let before = *b.lists.borrow();
|
let before = *b.lists.borrow();
|
||||||
@@ -946,14 +964,22 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.path.as_str().to_string())
|
.map(|e| e.path.as_str().to_string())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec!["Photos/2025/a.drsc", "Photos/2025/b.DRSC"]
|
vec![
|
||||||
|
"Photos/2025/a.drsc",
|
||||||
|
"Photos/2025/a.xmp",
|
||||||
|
"Photos/2025/b.DRSC",
|
||||||
|
"Photos/2025/b.jpg.xmp",
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
|
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
|
||||||
|
|
||||||
// And they are not photographs: the count the user is shown must not
|
// And they are not photographs: the count the user is shown must not
|
||||||
// double because a library has been edited.
|
// double because a library has been edited.
|
||||||
assert_eq!(r.progress.images_found, 3);
|
assert_eq!(r.progress.images_found, 3);
|
||||||
assert!(r.images.iter().all(|e| !e.path.name().contains("drsc")));
|
assert!(r
|
||||||
|
.images
|
||||||
|
.iter()
|
||||||
|
.all(|e| !e.path.name().contains("drsc") && !e.path.name().contains("xmp")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file whose *name* is only an extension is not a sidecar for anything.
|
/// A file whose *name* is only an extension is not a sidecar for anything.
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ pub struct LibrarySettings {
|
|||||||
/// 64 bars on a phone held in the hand is finer than a finger can aim at;
|
/// 64 bars on a phone held in the hand is finer than a finger can aim at;
|
||||||
/// 32 on a desktop monitor wastes most of a tall sidebar.
|
/// 32 on a desktop monitor wastes most of a tall sidebar.
|
||||||
pub timeline_bars: u32,
|
pub timeline_bars: u32,
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// Whether a judgement is also written to the standard XMP sidecar beside
|
||||||
|
/// the original — `IMG_0001.xmp`, or `IMG_0001.CR3.xmp` where one exists.
|
||||||
|
///
|
||||||
|
/// Off by default, because NFR-R4 says writes beside somebody's originals
|
||||||
|
/// are theirs to switch on. Reading is not gated: a sidecar another
|
||||||
|
/// editor wrote is taken in regardless, since reading changes nothing in
|
||||||
|
/// the folder.
|
||||||
|
pub write_xmp_sidecars: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibrarySettings {
|
impl LibrarySettings {
|
||||||
@@ -106,7 +115,10 @@ impl Default for LibrarySettings {
|
|||||||
// The coarser of the two. A bar has to be wide enough to hit with a
|
// The coarser of the two. A bar has to be wide enough to hit with a
|
||||||
// finger before it has to be narrow enough to be precise, and the
|
// finger before it has to be narrow enough to be precise, and the
|
||||||
// smallest screen is the one where getting this wrong hurts most.
|
// smallest screen is the one where getting this wrong hurts most.
|
||||||
Self { timeline_bars: 32 }
|
Self {
|
||||||
|
timeline_bars: 32,
|
||||||
|
write_xmp_sidecars: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# DarkRoom — reproducible Windows cross-build environment
|
||||||
|
#
|
||||||
|
# Everything docs/windows.md §2 names: Rust with the GNU Windows target, the
|
||||||
|
# MinGW-w64 cross compiler it links with, NSIS to build the installer, and Wine
|
||||||
|
# to smoke-test the result. Both CI and local builds use this image, so "works
|
||||||
|
# on my machine" and "works in CI" are the same machine — the same argument
|
||||||
|
# docker/android makes, and the same shape.
|
||||||
|
#
|
||||||
|
# Build: docker build -t darkroom-windows:latest docker/windows
|
||||||
|
# Use: ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
|
||||||
|
# trixie rather than the Android image's bookworm, for Wine: rustc's std
|
||||||
|
# imports bcryptprimitives.dll for its random source, and bookworm's Wine 8.0
|
||||||
|
# does not have it, so the smoke test dies at load with c0000135 before a
|
||||||
|
# single instruction of the application runs. Wine 10 does. trixie also ships
|
||||||
|
# Node 20 itself, so the NodeSource step the Android image needs is not here.
|
||||||
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Versions — pinned deliberately, like the Android image.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ARG RUST_VERSION=1.92.0
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
CARGO_HOME=/opt/cargo \
|
||||||
|
RUSTUP_HOME=/opt/rustup \
|
||||||
|
PATH=/opt/cargo/bin:$PATH
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System packages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git git-lfs \
|
||||||
|
# A *host* C compiler as well as the cross one: build scripts and
|
||||||
|
# proc-macros are compiled for Linux and linked with `cc`, whatever
|
||||||
|
# the target. Without it the very first build script fails with
|
||||||
|
# "linker `cc` not found" before any Windows code is reached.
|
||||||
|
gcc libc6-dev \
|
||||||
|
# The cross compiler, binutils and the MinGW runtime headers/libs. This
|
||||||
|
# is the one C toolchain the target needs: bundled SQLite, ring's asm
|
||||||
|
# and anything else the cc crate builds for the target go through it.
|
||||||
|
gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
|
||||||
|
# The installer compiler. A native Linux binary; NSIS has always built
|
||||||
|
# its installers on POSIX hosts.
|
||||||
|
nsis \
|
||||||
|
# Runs the .exe and the installer for the smoke tests (windows.md §6).
|
||||||
|
# Not needed to build anything. Both packages: `wine64` is the
|
||||||
|
# loader under /usr/lib/wine, `wine` is the wrapper on PATH.
|
||||||
|
wine wine64 \
|
||||||
|
# `file` reports PE32+; `xz-utils` because the mingw packages are
|
||||||
|
# compressed with it.
|
||||||
|
file xz-utils \
|
||||||
|
# Gitea runs JavaScript actions (checkout, cache) from inside the job
|
||||||
|
# container, and current actions want Node 20 or newer.
|
||||||
|
nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& node --version
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rust + the Windows target
|
||||||
|
#
|
||||||
|
# The component list must be a superset of rust-toolchain.toml's, for the
|
||||||
|
# reason the Android Dockerfile gives: rustup reconciles that file on the
|
||||||
|
# first cargo invocation and downloads anything missing inside the job.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||||
|
-y --no-modify-path --profile minimal --default-toolchain ${RUST_VERSION} \
|
||||||
|
&& rustup target add x86_64-pc-windows-gnu \
|
||||||
|
&& rustup component add rustfmt clippy rust-analyzer \
|
||||||
|
&& chmod -R a+rwX ${CARGO_HOME} ${RUSTUP_HOME}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Linker configuration
|
||||||
|
#
|
||||||
|
# Debian ships the cross compiler in two thread models and the bare name is an
|
||||||
|
# alternatives symlink. `-posix` is stated: it is the one whose libstdc++ and
|
||||||
|
# libwinpthread the Rust target's own MinGW pieces were built against, and
|
||||||
|
# picking the other produces link errors that read as if std were missing.
|
||||||
|
#
|
||||||
|
# The runtime is linked statically (docs/windows.md §2) so the installer
|
||||||
|
# carries one file. `-static-libgcc` is all it takes: rustc's windows-gnu
|
||||||
|
# target links its own copy of winpthread in self-contained mode, so nothing
|
||||||
|
# imports libwinpthread-1.dll — the smoke test's objdump step is what checks
|
||||||
|
# that. The `--whole-archive -lwinpthread` incantation the spec first named is
|
||||||
|
# wrong here: it forces in unused winpthread objects whose kernel32 and
|
||||||
|
# msvcrt references come after those libraries on the link line, and the
|
||||||
|
# link fails on a hundred undefined `__imp_` symbols.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix \
|
||||||
|
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++" \
|
||||||
|
CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-posix \
|
||||||
|
CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++-posix \
|
||||||
|
AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-ar-posix \
|
||||||
|
WINDRES=x86_64-w64-mingw32-windres
|
||||||
|
|
||||||
|
# Wine writes its prefix under $HOME and refuses a directory it does not own.
|
||||||
|
# The caller passes --user, so nothing baked into the image can be owned by
|
||||||
|
# that user; build.sh bind-mounts a host directory here instead, which also
|
||||||
|
# keeps the prefix (and its slow first `wineboot`) across runs.
|
||||||
|
ENV HOME=/tmp/home \
|
||||||
|
WINEDEBUG=-all
|
||||||
|
|
||||||
|
VOLUME ["/opt/cargo/registry"]
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Windows cross-build environment
|
||||||
|
|
||||||
|
Reproducible container for building the Windows executable and its installer from Linux. The
|
||||||
|
specification is [docs/windows.md](../../docs/windows.md); this directory is what it turned into,
|
||||||
|
and every departure from the spec's first draft is recorded in the Dockerfile's comments.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cross-compile the desktop application
|
||||||
|
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
|
||||||
|
# Lint the cfg(windows) branches, which the Linux job never sees
|
||||||
|
./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
|
||||||
|
|
||||||
|
# Build the installer from that binary
|
||||||
|
./docker/windows/build.sh docker/windows/package.sh
|
||||||
|
|
||||||
|
# Smoke-test under Wine (docs/windows.md §6)
|
||||||
|
./docker/windows/build.sh wine target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe --version
|
||||||
|
./docker/windows/build.sh wine target-windows/installer/DarkRoom-0.12.0-x86_64-setup.exe /S
|
||||||
|
|
||||||
|
# Interactive shell
|
||||||
|
./docker/windows/build.sh
|
||||||
|
|
||||||
|
# After editing the Dockerfile
|
||||||
|
./docker/windows/build.sh --rebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefers `podman`, falls back to `docker`. The cargo registry, the target directory and the Wine
|
||||||
|
prefix persist under `~/.cache/darkroom-windows/`, so a warm rebuild is minutes and the first
|
||||||
|
`wineboot` happens once.
|
||||||
|
|
||||||
|
## What is verified here, and what is not
|
||||||
|
|
||||||
|
Measured on the first build, 2026-09-12:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `cargo build --target x86_64-pc-windows-gnu -p darkroom-desktop` | Links. 115 MB, `PE32+ … (GUI)` |
|
||||||
|
| DLL imports | 26 Windows system DLLs; **no** `libwinpthread-1.dll`, `libgcc_s`, `libstdc++` |
|
||||||
|
| `wine darkroom-desktop.exe --version` | `darkroom-desktop 0.12.0`, exit 0, 0.1 s |
|
||||||
|
| `makensis` | 105 MB `DarkRoom-<version>-x86_64-setup.exe`, 64-bit stub |
|
||||||
|
| `wine setup.exe /S` | Installs exe + 7 models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs |
|
||||||
|
| `wine uninstall.exe /S` | Removes the directory and the key |
|
||||||
|
| Start Menu shortcut | **Not verifiable here.** `CreateShortcut` is `IShellLink` and does nothing under a headless Wine; the directory beside it is created. Check on Windows. |
|
||||||
|
|
||||||
|
Also checked by running the application itself under Wine: its log lands in
|
||||||
|
`AppData\Local\darkroom\state`, and nothing is written outside `AppData` (FR-PLAT-WIN-1).
|
||||||
|
|
||||||
|
None of this proves a Vulkan device opens, a render completes, fonts are found, the secret store
|
||||||
|
round-trips or the browser opens for sign-in. Those are a Windows machine, once per release.
|
||||||
|
|
||||||
|
## In CI
|
||||||
|
|
||||||
|
`.gitea/workflows/windows-image.yml` builds this image and pushes it to
|
||||||
|
`gitea.tourolle.paris/dtourolle/darkroom-windows`, exactly as the Android image is handled — tagged
|
||||||
|
by the tree hash of `docker/windows/`, rebuilt when and only when a file here changes. The
|
||||||
|
`windows` job in `build-and-test.yml` then runs, inside it, the same commands listed above plus
|
||||||
|
`cargo clippy --target x86_64-pc-windows-gnu -- -D warnings`, and uploads the installer as an
|
||||||
|
artefact.
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run a command inside the DarkRoom Windows cross-build container.
|
||||||
|
#
|
||||||
|
# ./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
# ./docker/windows/build.sh cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
# ./docker/windows/build.sh # interactive shell
|
||||||
|
#
|
||||||
|
# Builds the image on first use. Rebuild after editing the Dockerfile with:
|
||||||
|
# ./docker/windows/build.sh --rebuild
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="darkroom-windows:latest"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
|
||||||
|
# Prefer podman (rootless by default); fall back to docker.
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
ENGINE=podman
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
ENGINE=docker
|
||||||
|
else
|
||||||
|
echo "error: neither podman nor docker found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--rebuild" ]]; then
|
||||||
|
shift
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
elif ! "${ENGINE}" image exists "${IMAGE}" 2>/dev/null && \
|
||||||
|
! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||||
|
echo "==> building ${IMAGE} (first run; several minutes)"
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Persist the cargo registry and target dir across runs, or every build
|
||||||
|
# re-downloads the crate index.
|
||||||
|
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-windows"
|
||||||
|
# `home` is the container's $HOME: Wine keeps its prefix there for the smoke
|
||||||
|
# tests, and refuses one it does not own — which rules out anything the image
|
||||||
|
# could have created, since the container runs as the host user.
|
||||||
|
mkdir -p "${CACHE}/registry" "${CACHE}/target" "${CACHE}/home"
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
--rm
|
||||||
|
-v "${REPO}:/work:z"
|
||||||
|
-v "${CACHE}/registry:/opt/cargo/registry:z"
|
||||||
|
-v "${CACHE}/target:/work/target-windows:z"
|
||||||
|
-v "${CACHE}/home:/tmp/home:z"
|
||||||
|
-e CARGO_TARGET_DIR=/work/target-windows
|
||||||
|
-w /work
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build parallelism. A full cross-compile will otherwise take every thread on
|
||||||
|
# the host and make the machine unusable for the length of the build, which is
|
||||||
|
# a poor trade when it is running in the background.
|
||||||
|
#
|
||||||
|
# Both halves are needed: CARGO_BUILD_JOBS caps how many rustc processes cargo
|
||||||
|
# starts, while --cpus caps what the container gets no matter what any nested
|
||||||
|
# build script decides to spawn (cc, cmake, and ring's asm build all parallelise
|
||||||
|
# on their own account and do not consult cargo).
|
||||||
|
JOBS="${DARKROOM_BUILD_JOBS:-8}"
|
||||||
|
if [[ "${JOBS}" != "0" ]]; then
|
||||||
|
ARGS+=(--cpus "${JOBS}" -e "CARGO_BUILD_JOBS=${JOBS}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rootless podman already maps the host user; docker needs it stated.
|
||||||
|
if [[ "${ENGINE}" == "docker" ]]; then
|
||||||
|
ARGS+=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
ARGS+=(-it)
|
||||||
|
set -- /bin/bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Assemble the Windows installer from a finished cross-build.
|
||||||
|
#
|
||||||
|
# ./docker/windows/build.sh docker/windows/package.sh
|
||||||
|
#
|
||||||
|
# Expects `cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop`
|
||||||
|
# to have run in the same target directory. Produces
|
||||||
|
# DarkRoom-<version>-x86_64-setup.exe in $OUT (default: target-windows/installer).
|
||||||
|
#
|
||||||
|
# Runs inside the container, where makensis is; docs/windows.md §5 is the
|
||||||
|
# specification this implements.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
# Absolute, whatever CARGO_TARGET_DIR was. NSIS on a POSIX host translates
|
||||||
|
# the backslashes in `${STAGE}\LICENSE` only when it can tell the path is a
|
||||||
|
# POSIX one, which it decides from a leading `/`; a relative `target-windows`
|
||||||
|
# — which is exactly what CI sets — reaches it as a name with a literal
|
||||||
|
# backslash in it and "LicenseData: open failed". The first CI run died there,
|
||||||
|
# after every step before it had passed.
|
||||||
|
TARGET_DIR="$(realpath -m "${CARGO_TARGET_DIR:-${REPO}/target}")"
|
||||||
|
TARGET="${TARGET_DIR}/x86_64-pc-windows-gnu/release"
|
||||||
|
OUT="$(realpath -m "${OUT:-${TARGET_DIR}/installer}")"
|
||||||
|
|
||||||
|
EXE="${TARGET}/darkroom-desktop.exe"
|
||||||
|
[[ -f "${EXE}" ]] || { echo "error: ${EXE} not built" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The version, from the workspace rather than restated here — the same single
|
||||||
|
# source tools/set-version.sh writes and the APK packager reads.
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
|
||||||
|
[[ -n "${VERSION}" ]] || { echo "error: no version in Cargo.toml" >&2; exit 1; }
|
||||||
|
echo "==> version ${VERSION}"
|
||||||
|
|
||||||
|
# Staging: exactly what the installer carries (windows.md §5.2), and nothing
|
||||||
|
# from a previous run — a model dropped from the tree must not linger here
|
||||||
|
# and go on shipping.
|
||||||
|
STAGE="${OUT}/stage"
|
||||||
|
rm -rf "${STAGE}"
|
||||||
|
mkdir -p "${STAGE}/models"
|
||||||
|
cp "${EXE}" "${STAGE}/darkroom.exe"
|
||||||
|
# CRLF for the licence page: it is shown in a Windows edit control, which
|
||||||
|
# draws a bare LF as nothing and runs the whole GPL together.
|
||||||
|
sed 's/$/\r/' "${REPO}/LICENSE" > "${STAGE}/LICENSE"
|
||||||
|
|
||||||
|
# The models, with the guard every other packager carries: an LFS pointer is
|
||||||
|
# ~130 bytes and looks exactly like a model to `cp`. Shipped, it fails inside
|
||||||
|
# tract on the user's machine with a message about a broken graph rather than
|
||||||
|
# a checkout that needed `git lfs pull`. Only the weights are checked; the
|
||||||
|
# scene model's vocabulary and category descriptor are legitimately small.
|
||||||
|
for dir in face scene; do
|
||||||
|
for f in "${REPO}/models/${dir}"/*; do
|
||||||
|
case "$(basename "${f}")" in
|
||||||
|
README.md) continue ;;
|
||||||
|
esac
|
||||||
|
if [[ "${f}" == *.onnx && "$(stat -c%s "${f}")" -lt 100000 ]]; then
|
||||||
|
echo "error: $(basename "${f}") is $(stat -c%s "${f}") bytes — an LFS pointer, not a model." >&2
|
||||||
|
echo " run: git lfs pull" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "${f}" "${STAGE}/models/"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
|
||||||
|
|
||||||
|
# One installer in the output directory, the one just built. The directory
|
||||||
|
# is cached between CI runs, so after a version bump a glob over it would find
|
||||||
|
# two and the smoke test would hand Wine both names as one path.
|
||||||
|
rm -f "${OUT}"/DarkRoom-*-x86_64-setup.exe
|
||||||
|
INSTALLER="${OUT}/DarkRoom-${VERSION}-x86_64-setup.exe"
|
||||||
|
# NSIS wants Windows-style paths in File directives even on a POSIX host, and
|
||||||
|
# the script takes its inputs by define so nothing about the layout here is
|
||||||
|
# written into it.
|
||||||
|
makensis -V2 \
|
||||||
|
-DVERSION="${VERSION}" \
|
||||||
|
-DSTAGE="${STAGE}" \
|
||||||
|
-DOUT="${INSTALLER}" \
|
||||||
|
"${REPO}/packaging/windows/darkroom.nsi"
|
||||||
|
|
||||||
|
ls -la "${INSTALLER}"
|
||||||
|
echo "==> ${INSTALLER}"
|
||||||
@@ -22,8 +22,9 @@ permission to a package rather than after.
|
|||||||
| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all |
|
| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all |
|
||||||
| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs |
|
| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs |
|
||||||
| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering |
|
| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering |
|
||||||
|
| Windows | NSIS per-user installer, cross-built — [windows.md](windows.md) | Built by CI, **untested on Windows**. Not v1 | Known folders in place of XDG; no sandbox; unsigned until there is a certificate |
|
||||||
|
|
||||||
Three of these five exist as recipes and two do not. That is stated rather than
|
Four of these six exist as recipes and two do not. That is stated rather than
|
||||||
smoothed over, because the value of writing the channels down is knowing which
|
smoothed over, because the value of writing the channels down is knowing which
|
||||||
constraints are already being met and which are promises.
|
constraints are already being met and which are promises.
|
||||||
|
|
||||||
@@ -245,6 +246,8 @@ packaging/
|
|||||||
paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel
|
paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel
|
||||||
flatpak/
|
flatpak/
|
||||||
paris.tourolle.darkroom.yml the manifest, and where the permissions are argued
|
paris.tourolle.darkroom.yml the manifest, and where the permissions are argued
|
||||||
|
windows/
|
||||||
|
darkroom.nsi the installer; docker/windows/package.sh drives it
|
||||||
```
|
```
|
||||||
|
|
||||||
`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local
|
`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local
|
||||||
|
|||||||
+44
-15
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||||
|
|
||||||
37 gestures, in 4 places.
|
40 gestures, in 4 places.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1950`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1974`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2041`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2065`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2128`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2152`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2349`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2373`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, or press Redo in the History header
|
- **Pointer** — Click it, or press Redo in the History header
|
||||||
- **Keyboard** — Ctrl+Shift+Z
|
- **Keyboard** — Ctrl+Shift+Z
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2362`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2386`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2395`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2419`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2407`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2431`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2435`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2459`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2470`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2494`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2500`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2546`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -121,25 +121,45 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2624`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2670`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
- **Touch** — Double-tap its track
|
- **Touch** — Double-tap its track
|
||||||
- **Pointer** — Double-click its track, or right-click it
|
- **Pointer** — Double-click its track, or right-click it
|
||||||
|
- **Keyboard** — R, for the control last moved
|
||||||
|
|
||||||
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
|
The column is 280px wide and the colour mixer alone puts thirty-six of these in it, so a reset button per row would be most of the width. Two ways in with a pointer because right-click is the one a hand already reaches for and double-click is the one that needs no second button. A group's own reset is in its heading; this is the single control.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/controls.slint:294`</sub>
|
<sub>`ui/dr-ui/ui/controls.slint:294`</sub>
|
||||||
|
|
||||||
|
### See the photograph as a snapshot had it
|
||||||
|
|
||||||
|
- **Touch** — Press and hold the eye beside the snapshot
|
||||||
|
- **Pointer** — Press and hold the eye beside the snapshot
|
||||||
|
|
||||||
|
The same hold as "Before", against a point the photographer chose rather than the file: "the version I liked twenty minutes ago" is how a choice between two treatments is actually made. It takes no history step and changes nothing; letting go puts the edit back.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/history.slint:92`</sub>
|
||||||
|
|
||||||
|
### Keep the photograph as it is now, under a name
|
||||||
|
|
||||||
|
- **Touch** — Type a name in the History panel and press Snapshot
|
||||||
|
- **Pointer** — Type a name in the History panel and press Snapshot
|
||||||
|
|
||||||
|
The history is forgotten with the sitting, on purpose; a snapshot is the photographer saying this one should not be. It is written into the sidecar as a version of the edit, so it survives a restart and reaches the other device. Pressing a snapshot puts the photograph back to it, as one step that undo takes back whole.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/history.slint:307`</sub>
|
||||||
|
|
||||||
### Show or hide one mask layer
|
### Show or hide one mask layer
|
||||||
|
|
||||||
- **Touch** — Tap the ring at the head of its row
|
- **Touch** — Tap the ring at the head of its row
|
||||||
- **Pointer** — Click the ring at the head of its row
|
- **Pointer** — Click the ring at the head of its row
|
||||||
|
- **Keyboard** — H, for the selected layer history step, unlike holding "Before" — the layer really is off until it is switched back on.
|
||||||
|
|
||||||
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a history step, unlike holding "Before" — the layer really is off until it is switched back on.
|
Disabling a layer is the before-and-after a local edit constantly wants, so it is one press away rather than inside the row. It is an edit and does take a
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:209`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:212`</sub>
|
||||||
|
|
||||||
### Show or hide one mask on the photograph
|
### Show or hide one mask on the photograph
|
||||||
|
|
||||||
@@ -148,7 +168,16 @@ Disabling a layer is the before-and-after a local edit constantly wants, so it i
|
|||||||
|
|
||||||
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
|
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/masks.slint:274`</sub>
|
<sub>`ui/dr-ui/ui/masks.slint:279`</sub>
|
||||||
|
|
||||||
|
### Leave one part out of a mask, and put it back
|
||||||
|
|
||||||
|
- **Touch** — Tap the ring on the part's row
|
||||||
|
- **Pointer** — Click the ring on the part's row
|
||||||
|
|
||||||
|
The question a correction raises is whether it did what it was for — whether the stroke filled the shoulder, whether the subtracted gradient took only the sky. Removing it answers that and loses it. The same ring the layer wears, one row down, because it is the same question about a smaller thing.
|
||||||
|
|
||||||
|
<sub>`ui/dr-ui/ui/masks.slint:394`</sub>
|
||||||
|
|
||||||
## Collections sidebar
|
## Collections sidebar
|
||||||
|
|
||||||
|
|||||||
+31
-24
@@ -244,11 +244,14 @@ requirement singles out are missing: whether `shaderFloat16` and 16-bit storage
|
|||||||
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
|
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
|
||||||
from a second GPU vendor.
|
from a second GPU vendor.
|
||||||
|
|
||||||
**NFR-OPS-2 and NFR-OPS-4.** Crash reporting is a `log::error!` panic hook on Android and nothing at
|
**NFR-OPS-2 is met, and NFR-OPS-4 is not.** Crash reporting is `platform/dr-plat/src/crash.rs`: a
|
||||||
all on desktop: no local crash record, no backtrace capture, no upload path and therefore no opt-in
|
panic on either platform writes a local record with a redacted message and backtrace, ten are kept,
|
||||||
gate to guard it. Update and first run are undefined; the concrete reason NFR-OPS-4 gives — that D2
|
and there is deliberately no upload path — the requirement's "upload only on explicit opt-in" is
|
||||||
pins rawler at a non-SemVer alpha whose camera-support fixes users will need — is unaddressed, and
|
satisfied by there being nothing to opt into, and the module says why a transport built ahead of the
|
||||||
there is no update mechanism of any kind.
|
consent is the wrong order. (This paragraph said the opposite until 2026-09-12; the record had landed
|
||||||
|
on 2026-08-30 and the paragraph had not been read against it.) Update and first run are undefined;
|
||||||
|
the concrete reason NFR-OPS-4 gives — that D2 pins rawler at a non-SemVer alpha whose camera-support
|
||||||
|
fixes users will need — is unaddressed, and there is no update mechanism of any kind.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -305,7 +308,7 @@ well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library int
|
|||||||
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
|
gap is narrower than it reads. It is the *first* build against a large remote library that pays, and
|
||||||
that is the moment a new user meets.
|
that is the moment a new user meets.
|
||||||
|
|
||||||
**FR-CAT-13 — XMP interoperability, built but not yet wired.** `core/dr-xmp` now reads and writes
|
**FR-CAT-13 — XMP interoperability, wired on 2026-09-12.** `core/dr-xmp` reads and writes
|
||||||
standard XMP sidecars: `dc:subject` and `lr:hierarchicalSubject`, `xmp:Rating` and `xmp:Label`, and
|
standard XMP sidecars: `dc:subject` and `lr:hierarchicalSubject`, `xmp:Rating` and `xmp:Label`, and
|
||||||
the IPTC core fields, in both the attribute and the element form and whatever RDF container a file
|
the IPTC core fields, in both the attribute and the element form and whatever RDF container a file
|
||||||
happened to use. It states the ownership rule in one place — DarkRoom owns the properties in
|
happened to use. It states the ownership rule in one place — DarkRoom owns the properties in
|
||||||
@@ -313,20 +316,22 @@ happened to use. It states the ownership rule in one place — DarkRoom owns the
|
|||||||
and enforces it by rewriting a packet event by event rather than serialising over it, so another
|
and enforces it by rewriting a packet event by event rather than serialising over it, so another
|
||||||
application's `crs:` settings, comments and processing instructions survive a write byte for byte.
|
application's `crs:` settings, comments and processing instructions survive a write byte for byte.
|
||||||
|
|
||||||
**What remains is the wiring, and it is the larger half.** Nothing above the crate calls it: no scan
|
**The wiring is `ui/dr-ui/src/xmp_sync.rs`.** The scan collects `.xmp` beside `.drsc` from the
|
||||||
finds a `.xmp` beside a raw, no catalog row is populated from one, no edit writes one back, and the
|
listings it was already making; the pull reads each one whose ETag has moved and reconciles it
|
||||||
external-modification detection the requirement also asks for does not exist. Two smaller gaps go
|
against the catalog with the catalog winning — keywords union, and a rating, label or caption taken
|
||||||
with it — GPS is not carried (`exif:GPSLatitude` is a format of its own, and `dr-decode` produces no
|
only where the catalog holds none. Both naming conventions resolve: `IMG_0001.CR3.xmp` names its
|
||||||
location for it to carry yet, which `dr-export`'s metadata module says about its own half), and the
|
file, `IMG_0001.xmp` the stem, and the JPEG beside a RAW is the same photograph. A genuine
|
||||||
filename convention is left to the caller, because Lightroom writes `IMG_0001.xmp` and darktable
|
disagreement is written to `xmp_conflicts` and the settings page offers "Take the sidecars' values",
|
||||||
writes `IMG_0001.CR3.xmp` and finding a file is not this crate's business.
|
which is the reload the requirement asks for; the detection it asks for is the ETag that moved. The
|
||||||
|
write in the other direction is behind a setting that starts off (NFR-R4): a judgement or a keyword
|
||||||
|
then also rewrites the sidecar beside the original, keeping the file's own caption, copyright and
|
||||||
|
hierarchy, which the catalog has no columns for and would otherwise have deleted.
|
||||||
|
|
||||||
The precedence question is settled conservatively rather than fully: keywords union, following
|
**What remains.** GPS is not carried — `exif:GPSLatitude` is a format of its own and `dr-decode`
|
||||||
`dr_catalog::merge`, and every other field is taken only where DarkRoom holds none, following
|
produces no location for it to carry yet. Title, description and copyright are read and reconciled
|
||||||
`Version::merge`'s judgement rule — because a standard XMP carries no revision and no device, so
|
but the catalog has nowhere to put them, so they pass through a rewrite rather than being editable.
|
||||||
FR-NC-9's ordering cannot be performed against it. What is *not* settled, and is written down in the
|
An XMP write made offline is not queued: the catalog and the `.drsc` are authoritative, and the next
|
||||||
crate rather than guessed at, is when a reload may happen without asking; see the module
|
judgement online writes the file whole again.
|
||||||
documentation's "The open question".
|
|
||||||
|
|
||||||
`dr-preset-xmp` remains what it always was and is still not the counter-example it looks like: a
|
`dr-preset-xmp` remains what it always was and is still not the counter-example it looks like: a
|
||||||
reader of Lightroom *presets* under FR-DEV-6, a different file for a different purpose.
|
reader of Lightroom *presets* under FR-DEV-6, a different file for a different purpose.
|
||||||
@@ -388,11 +393,13 @@ line — and R1 is untagged again, which is the honest reading while it has no a
|
|||||||
tag anything against.
|
tag anything against.
|
||||||
NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the
|
NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the
|
||||||
two: one on `compute_coverage` and one on the gesture extractor, both on the traceability tool. A
|
two: one on `compute_coverage` and one on the gesture extractor, both on the traceability tool. A
|
||||||
coverage calculation and a documentation generator are not diagnostics under any reading, and the
|
coverage calculation and a documentation generator are not diagnostics under any reading, so both
|
||||||
requirement asks for a rotating, size-capped on-disk log in the XDG state directory, credential
|
tags were removed. It is the case [CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words:
|
||||||
redaction, and a consented diagnostics bundle. None of that exists — logging goes to stderr and
|
a tag proves a tag exists. The requirement has since been built where it says: the rotating,
|
||||||
logcat — so both tags have been removed and NFR-OPS-1 is untagged. It is the case
|
size-capped log and its redaction in `platform/dr-plat/src/diagnostics.rs` (2026-08-30), and the
|
||||||
[CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words: a tag proves a tag exists.
|
bundle in `diagnostics/bundle.rs` (2026-09-12) — the log, the crash records, the version, the schema
|
||||||
|
and the GPU as one text file, shown in full in Settings before a second press writes it, and sent
|
||||||
|
nowhere by either press.
|
||||||
|
|
||||||
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
|
**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure
|
||||||
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
|
TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated
|
||||||
|
|||||||
@@ -533,6 +533,17 @@ which knows nothing of a layer's shaping and nothing at all about a gradient, a
|
|||||||
so choosing a subject or a category produced a layer whose extent was invisible, and every control
|
so choosing a subject or a category produced a layer whose extent was invisible, and every control
|
||||||
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
|
in FR-DEV-19a and FR-DEV-19b acted on something the photographer could not see.
|
||||||
|
|
||||||
|
**FR-DEV-16 — A keyboard vocabulary for develop.** Every develop gesture that can be reached from
|
||||||
|
the keyboard is bound, tagged beside its implementation, and generated into the gesture book
|
||||||
|
(FR-UI-4): stepping through the folder, fit and 1:1, holding the original, undo and redo, copying
|
||||||
|
and pasting settings, cycling the adjustment groups, resetting the control last moved, and showing
|
||||||
|
or hiding the selected mask layer. None is keyboard-only — each has a pointer and a touch route
|
||||||
|
(FR-DEV-3b) — and the book is regenerated from the tags, so it cannot describe a binding the
|
||||||
|
application does not have.
|
||||||
|
Editing rhythm depends on the hands staying put: reaching for a menu breaks the concentration of an
|
||||||
|
edit the same way it breaks the pace of a cull. A binding nobody can discover is the same as no
|
||||||
|
binding, which is why the generated book is part of the requirement rather than documentation of it.
|
||||||
|
|
||||||
### 3.4 Display and interaction
|
### 3.4 Display and interaction
|
||||||
|
|
||||||
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
|
**FR-DSP-1 — Proxy-resolution rendering.** The develop view renders at the resolution actually
|
||||||
@@ -1009,6 +1020,24 @@ protocol is unavailable, FR-DSP-8's stated fallback applies.
|
|||||||
portals and credential storage uses the Secret Service portal, both verified to satisfy FR-NC-2 and
|
portals and credential storage uses the Secret Service portal, both verified to satisfy FR-NC-2 and
|
||||||
FR-CAT-1 within the sandbox.
|
FR-CAT-1 within the sandbox.
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
Specified in [windows.md](windows.md); a stated channel under NFR-COMPAT-2, not a v1 one.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-1 — Known folders.** Configuration under `%APPDATA%\darkroom`; data, cache and
|
||||||
|
state under `%LOCALAPPDATA%\darkroom`. Nothing under the profile root and nothing relative to the
|
||||||
|
working directory. The layout beneath those roots is the same as under XDG, so a library directory
|
||||||
|
moves between platforms unchanged.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-2 — Installer.** A per-user installer that needs no elevation, registers an
|
||||||
|
uninstaller, and whose uninstaller removes what the installer wrote and nothing the application
|
||||||
|
wrote. Models are installed beside the executable and found there last, after the user's own
|
||||||
|
directories.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-3 — Built from Linux.** The Windows binary and its installer are produced by the
|
||||||
|
Linux CI from the same commit as every other channel, with no Windows machine in the build.
|
||||||
|
Verification on Windows is a release step, recorded per release, not a build step.
|
||||||
|
|
||||||
### 3.9 Culling
|
### 3.9 Culling
|
||||||
|
|
||||||
Per D11 this is the product's primary differentiator, not an incidental capability.
|
Per D11 this is the product's primary differentiator, not an incidental capability.
|
||||||
|
|||||||
+100
-96
File diff suppressed because one or more lines are too long
+486
@@ -0,0 +1,486 @@
|
|||||||
|
# DarkRoom — A Windows installer from the Linux CI
|
||||||
|
|
||||||
|
**Satisfies:** FR-PLAT-WIN-1 · FR-PLAT-WIN-2 · FR-PLAT-WIN-3 · NFR-COMPAT-2 (a stated channel)
|
||||||
|
**Companion to:** [distribution.md](distribution.md) · [requirements.md](requirements.md) §3.8, §4.4 ·
|
||||||
|
[android-signing.md](android-signing.md)
|
||||||
|
|
||||||
|
Spec for producing `DarkRoom-<version>-x86_64-setup.exe` from the same Gitea runner that builds the
|
||||||
|
Arch package and the APK, with no Windows machine in the loop. It names the toolchain, what the
|
||||||
|
tree has to change to compile for the target, what the installer does, how the CI job is shaped,
|
||||||
|
and — because there is no Windows hardware on the runner — exactly how much of the result can be
|
||||||
|
verified before a person double-clicks it.
|
||||||
|
|
||||||
|
**Written as a spec; §10 is the report.** Every step of §9 has since been run —
|
||||||
|
[`docker/windows/`](../docker/windows/) is the container, [`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi)
|
||||||
|
the installer, [`.gitea/workflows/windows-image.yml`](../.gitea/workflows/windows-image.yml) and
|
||||||
|
the `windows` job in `build-and-test.yml` the CI leg, and §6's gate passes through row 4 under
|
||||||
|
Wine. Four claims in the first draft were wrong and are corrected in place with a note; §10 lists
|
||||||
|
them. Where a claim still rests on reading rather than running, it says so.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this is nearly free, and where it is not
|
||||||
|
|
||||||
|
The reason to write this at all is that the tree is closer to Windows than a Linux-only project
|
||||||
|
usually is. The three things that ordinarily make a cross-build to Windows a week of work are all
|
||||||
|
absent:
|
||||||
|
|
||||||
|
| Usual obstacle | Here |
|
||||||
|
|---|---|
|
||||||
|
| A C image library (libraw, libjpeg-turbo, lcms) | `rawler`, `zune-jpeg`, `jpeg-encoder`, all pure Rust |
|
||||||
|
| OpenSSL, or a TLS stack with a system dependency | `reqwest` on `rustls` + `webpki-roots`; `ring` cross-compiles to the GNU target |
|
||||||
|
| A GUI toolkit with a platform-specific build | Slint on `winit` + wgpu, which already runs the same code on Linux and Android |
|
||||||
|
|
||||||
|
`rusqlite` is `bundled`, so SQLite compiles with whatever C compiler the target has; that is the one
|
||||||
|
place a cross C compiler is required, and it is a package install rather than a port. The inference
|
||||||
|
engine is tract, in Rust, which is what [faces.md §3](faces.md) chose it for — and this is the
|
||||||
|
second time that choice pays: the C++ ONNX Runtime would have needed a prebuilt Windows binary
|
||||||
|
fetched at build time.
|
||||||
|
|
||||||
|
**Where it is not free** is `dr-plat` and the handful of paths above it, which is exactly where
|
||||||
|
NFR-PORT-1 says platform code should be and where §3 finds it. That the list in §3 is short and
|
||||||
|
every item on it is already behind a `cfg` is the measure of whether NFR-PORT-3 ("a third platform
|
||||||
|
requires implementing the platform interfaces only") was met. It was, nearly: the gaps are in
|
||||||
|
things that grew *above* `dr-plat` — a settings file path, an `xdg-open` — rather than in the
|
||||||
|
interfaces themselves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Toolchain: the GNU target, from a container
|
||||||
|
|
||||||
|
Two Rust targets can produce a Windows binary from Linux.
|
||||||
|
|
||||||
|
| Target | Linker | What it needs on the runner | What it costs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **`x86_64-pc-windows-gnu`** | MinGW-w64 `gcc` | `gcc-mingw-w64-x86-64` (Debian/Ubuntu), `mingw-w64-gcc` (Arch) — one apt/pacman install | Binaries link `libgcc_s` and `libwinpthread` unless told not to; the SEH unwinder is MinGW's rather than MSVC's; DirectX bindings are less exercised (not used — §2.1) |
|
||||||
|
| `x86_64-pc-windows-msvc` | `lld-link` via [`cargo-xwin`](https://github.com/rust-cross/cargo-xwin) | The MSVC CRT and Windows SDK headers, fetched from Microsoft's servers by `xwin` on first use (~1.5 GB, licence-accepted by flag) | A download step in CI that depends on Microsoft keeping those URLs stable, and a licence the runner accepts on the project's behalf |
|
||||||
|
|
||||||
|
**Decision: GNU.** It is a package install, it is what `rustup target add` supports out of the
|
||||||
|
box, and every crate in the dependency graph that carries a C component (`ring`, `libsqlite3-sys`,
|
||||||
|
`zstd-sys` if present) builds against MinGW today. The MSVC route produces a marginally more
|
||||||
|
conventional binary — the same CRT every other Windows application links — and costs a
|
||||||
|
1.5 GB fetch of Microsoft-licensed headers on every cold CI run. That is the wrong trade for a
|
||||||
|
channel whose users are, for now, the author.
|
||||||
|
|
||||||
|
Static-link the MinGW runtime so the installer carries one file rather than three. The
|
||||||
|
configuration lives in the container as environment variables rather than in a `.cargo/config.toml`
|
||||||
|
— that file is untracked here on purpose, and the Android image sets its linkers the same way:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix
|
||||||
|
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-args=-static-libgcc -C link-args=-static-libstdc++"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corrected.** The first draft added `-Wl,--whole-archive -lwinpthread` "so nothing imports
|
||||||
|
`libwinpthread-1.dll`". That flag breaks the link: forcing the whole archive drags in unused
|
||||||
|
winpthread objects whose kernel32 and msvcrt references land after those libraries on the link
|
||||||
|
line, and the build dies on a hundred undefined `__imp_` symbols. It was also unnecessary —
|
||||||
|
rustc's windows-gnu target links its own winpthread in self-contained mode, and the built
|
||||||
|
executable imports no MinGW library at all (§10). `-posix` is stated because Debian's bare
|
||||||
|
`x86_64-w64-mingw32-gcc` is an alternatives symlink to either thread model.
|
||||||
|
|
||||||
|
Two more things the container needs that the draft did not name: a **host** `gcc`, because build
|
||||||
|
scripts and proc-macros compile for Linux whatever the target and the very first one fails with
|
||||||
|
"linker `cc` not found" without it; and **Wine 10**, because rustc's std imports
|
||||||
|
`bcryptprimitives.dll` for its random source and Debian bookworm's Wine 8.0 does not have it —
|
||||||
|
the smoke test dies at load with `c0000135` before the first instruction. So the image is
|
||||||
|
`debian:trixie-slim`, which also ships Node 20 natively.
|
||||||
|
|
||||||
|
### 2.1 What the binary reaches at runtime
|
||||||
|
|
||||||
|
Nothing the installer has to carry. wgpu opens **Vulkan** (`dr_gpu::new_shared`, D1 — Vulkan on
|
||||||
|
both targets, and the shared-device path permits nothing else), and on Windows the Vulkan loader
|
||||||
|
`vulkan-1.dll` is installed by every GPU vendor's driver. Slint's femtovg renderer finds system
|
||||||
|
fonts through `fontdb`, so the `fontconfig` the Linux CI job installs has no Windows counterpart.
|
||||||
|
There is no `libxkbcommon`, no display-server library: `winit` speaks Win32 directly.
|
||||||
|
|
||||||
|
**DirectX 12 is deliberately not enabled.** wgpu supports it and on Windows would be the
|
||||||
|
conventional choice, but the develop pipeline's compute shaders are written once for Vulkan
|
||||||
|
(NFR-PORT-2) and validated on two Vulkan drivers already; a third backend is a third set of
|
||||||
|
driver behaviours to characterise (NFR-R1's tolerance argument), and no Windows machine that can
|
||||||
|
run this application lacks a Vulkan ICD. The same reasoning that keeps GL out of `new_shared`
|
||||||
|
keeps DX12 out here. It is one flag away if that turns out to be wrong.
|
||||||
|
|
||||||
|
### 2.2 Where it builds
|
||||||
|
|
||||||
|
The same shape as the Android leg: a job container built from a Dockerfile in the tree and pushed
|
||||||
|
to the Gitea registry, tagged by the tree id of its directory so an unrelated push reuses it
|
||||||
|
([`android-image.yml`](../.gitea/workflows/android-image.yml) already does this and the comment
|
||||||
|
there explains why).
|
||||||
|
|
||||||
|
```
|
||||||
|
docker/windows/
|
||||||
|
Dockerfile debian:trixie + rustup (1.92.0, target x86_64-pc-windows-gnu) + gcc + gcc-mingw-w64-x86-64 + nsis + wine
|
||||||
|
build.sh run a command in the container; caches registry, target and the Wine prefix
|
||||||
|
package.sh the LFS guard, staging, then makensis
|
||||||
|
```
|
||||||
|
|
||||||
|
`makensis` is a Linux binary; NSIS has always been buildable and runnable on POSIX hosts, and
|
||||||
|
Debian ships it as `nsis`. Wine is in the image for §6, not for the build. `osslsigncode` is not
|
||||||
|
in it until there is a certificate to give it (§5.4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What the tree has to change
|
||||||
|
|
||||||
|
Read from the source, not run. Everything here is `dr-plat` or the thin layer above it — nothing
|
||||||
|
in `core/` is touched, which is NFR-PORT-1 holding.
|
||||||
|
|
||||||
|
### 3.1 Already handled
|
||||||
|
|
||||||
|
- **`volumes.rs`** — card detection reads `/proc/mounts` and `/sys/block` under
|
||||||
|
`cfg(target_os = "linux")` and returns an empty list elsewhere. Windows gets no card detection
|
||||||
|
in this pass; the import flow's path picker still works. (A `GetDriveType`/`DRIVE_REMOVABLE`
|
||||||
|
implementation is a screen of code and a follow-up.)
|
||||||
|
- **`display.rs`** — the X11 and Wayland colour-profile readers are `cfg(all(unix, not(android)))`;
|
||||||
|
the fallback is FR-DSP-8's stated one. Windows ICC profiles via `GetICMProfile` are a follow-up
|
||||||
|
for the same reason.
|
||||||
|
- **`desktop_client.rs`** — the Nextcloud desktop client's Unix socket is `cfg(unix)`. On Windows
|
||||||
|
the client listens on a named pipe (`\\.\pipe\...`); until that is implemented FR-NC-6c's
|
||||||
|
integration is absent and the app behaves as it does on a Linux machine with no client running.
|
||||||
|
- **`secrets.rs`** — has a `PlatformSecretStore` for "any platform without an implementation"
|
||||||
|
that returns `SecretError::Unavailable` on every call. It is loud on purpose, so a Windows build
|
||||||
|
made with no further change *compiles*, starts, and fails at sign-in with a clear message. §3.2
|
||||||
|
is what turns that into a working store.
|
||||||
|
- **`keyring`, `x11rb`, `wayland-*`** are target-scoped dependencies already, so the Linux-only
|
||||||
|
crates are not even compiled.
|
||||||
|
|
||||||
|
### 3.2 Required before the installer is worth shipping
|
||||||
|
|
||||||
|
Ordered by what blocks a first sign-in. **All four are done**; each item says how.
|
||||||
|
|
||||||
|
1. **Secret store.** *Done.* `keyring` 4's `v1` feature set — the one the workspace already
|
||||||
|
asks for — includes `windows-native-keyring-store`, so the Credential Manager backend needed
|
||||||
|
no new feature name, only the crate as a `cfg(windows)` target dependency and the existing
|
||||||
|
Secret Service implementation's `cfg` widened to include Windows. One implementation over
|
||||||
|
both, because `keyring::Entry` is the same API over either; the only difference is that
|
||||||
|
`is_available`'s probe always succeeds on Windows, which is correct — Credential Manager is
|
||||||
|
always present, so FR-NC-2's degraded mode does not arise.
|
||||||
|
|
||||||
|
2. **Paths.** *Done* — [`platform/dr-plat/src/dirs.rs`](../platform/dr-plat/src/dirs.rs). FR-PLAT-LIN-1
|
||||||
|
says XDG, and the code said it in five places by reading `XDG_*_HOME` and falling back to
|
||||||
|
`$HOME/.local/...`. On Windows `HOME` is normally unset, so every one of these degraded to a
|
||||||
|
relative path from the working directory — which for a Start Menu launch is
|
||||||
|
`C:\Windows\System32`. Now one function per kind of directory in `dr-plat`, with the Windows
|
||||||
|
branch reading `%APPDATA%` (config; roams) and `%LOCALAPPDATA%` (data, cache, state; does
|
||||||
|
not), and the five call sites using it. The Android overrides (`set_state_dir`,
|
||||||
|
`set_data_dir`) stay where they were; only the fallback behind them moved. Both platforms'
|
||||||
|
rules are unit-tested on either host, and the Windows one was confirmed by running the
|
||||||
|
application under Wine: the log landed in `AppData\Local\darkroom\state` and nothing was
|
||||||
|
written anywhere else.
|
||||||
|
|
||||||
|
| Kind | Linux today | Windows |
|
||||||
|
|---|---|---|
|
||||||
|
| config (`settings.json`, accounts) | `$XDG_CONFIG_HOME/darkroom` — `dr_sync::config_dir`, `settings_store.rs` | `%APPDATA%\darkroom` |
|
||||||
|
| data (catalog, thumbnails, faces) | `$XDG_DATA_HOME/darkroom` — `library::data_root` | `%LOCALAPPDATA%\darkroom` |
|
||||||
|
| state (crash reports, diagnostics) | `$XDG_STATE_HOME/darkroom` — `state.rs`, `crash.rs` | `%LOCALAPPDATA%\darkroom\state` |
|
||||||
|
|
||||||
|
FR-PLAT-WIN-1 states this as the requirement. The catalog and thumbnail *formats* do not change,
|
||||||
|
so a library directory copied from a Linux machine opens.
|
||||||
|
|
||||||
|
3. **Face models.** *Done.* `library::system_face_models_dirs` walked `$XDG_DATA_DIRS`, which
|
||||||
|
does not exist on Windows. The rule moved to `dr_plat::system_data_dirs`: the installer puts
|
||||||
|
the models beside the executable (§5), so the Windows branch returns the executable's own
|
||||||
|
directory. The user-directory lookups above it are unchanged, so a hand-placed pair still
|
||||||
|
outranks the installed one, exactly as on Linux.
|
||||||
|
|
||||||
|
4. **Opening the sign-in URL.** *Done.* `launch_ui.rs` shelled out to `xdg-open`. The Windows
|
||||||
|
branch runs `rundll32 url.dll,FileProtocolHandler <url>`, which is `ShellExecute` on the URL
|
||||||
|
and needs no crate — chosen over `cmd /C start`, whose quoting of `&` in a query string is a
|
||||||
|
known trap, and over the `open` crate, which would be a dependency for one line. Android has
|
||||||
|
its own Intent path already, so this is the third branch of a function that already had two.
|
||||||
|
*Not verified*: Wine has no browser to open.
|
||||||
|
|
||||||
|
5. **`std::os::unix` uses outside a `cfg`.** `diagnostics.rs` and `presets.rs` use
|
||||||
|
`PermissionsExt` for mode bits on written files. Most are inside `#[cfg(unix)]` blocks already;
|
||||||
|
the first cross-compile will name any that are not, and the fix is a `cfg` rather than a
|
||||||
|
Windows ACL equivalent — the files in question are the user's own.
|
||||||
|
|
||||||
|
6. **The executable's identity.** *Done.* Windows takes the icon and the version block from a
|
||||||
|
resource compiled into the `.exe`, not from a `.desktop` file.
|
||||||
|
[`apps/darkroom-desktop/build.rs`](../apps/darkroom-desktop/build.rs) uses `winresource`
|
||||||
|
(which invokes MinGW's `windres` when cross-compiling) to embed
|
||||||
|
[`ui/dr-ui/ui/app-icon.png`](../ui/dr-ui/ui/app-icon.png) — wrapped into an `.ico` in
|
||||||
|
`OUT_DIR` at build time, since an ICO entry may be a PNG, so no generated binary is committed —
|
||||||
|
plus the version from `CARGO_PKG_VERSION` and the product name. The script returns before
|
||||||
|
touching the crate on every other target, and `winresource` is an unconditional
|
||||||
|
build-dependency because **a `cfg(windows)` on a build-dependency is evaluated against the
|
||||||
|
host**, which is Linux. This is the fifth place the identifier lives, and
|
||||||
|
[`tools/set-version.sh`](../tools/set-version.sh) does not need to learn it: the resource
|
||||||
|
reads the version cargo already knows. The same commit made the release binary a GUI-subsystem
|
||||||
|
executable (`windows_subsystem = "windows"`), or Windows keeps a console window open behind
|
||||||
|
the application.
|
||||||
|
|
||||||
|
Everything in this list is `cfg(windows)` code in `dr-plat` or a call-site switch in `dr-ui`, and
|
||||||
|
none of it touches the image core, the catalog schema, or the edit pipeline. That is the NFR-PORT-3
|
||||||
|
test, and it should be stated in the commit that closes the list whether it passed.
|
||||||
|
|
||||||
|
**What the first cross-compile actually found** (§9 step 2): nothing in this list blocked the
|
||||||
|
link. The whole graph compiled; the only warnings were two constants — `SERVICE` in `secrets.rs`
|
||||||
|
and `TIMEOUT` in `desktop_client.rs` — left unused by the `cfg`s that already shadow their users,
|
||||||
|
now guarded the same way. Items 1–4 are still open, and the binary starts without them; it just
|
||||||
|
cannot sign in.
|
||||||
|
|
||||||
|
### 3.3 Explicitly not in this pass
|
||||||
|
|
||||||
|
- **MIME/file-type registration** — FR-PLAT-LIN-1's `.desktop` MIME entries have a registry
|
||||||
|
equivalent (`HKCU\Software\Classes\.cr2` etc.). Not until the application opens a file from the
|
||||||
|
command line usefully, which `main.rs` accepts but the launch flow does not yet act on.
|
||||||
|
- **High-DPI declaration** — winit sets per-monitor-v2 awareness through its manifest by default.
|
||||||
|
Verified in winit's source, not on a monitor; if text is blurry on a 150% display this is the
|
||||||
|
first suspect.
|
||||||
|
- **Card detection, ICC profiles, the desktop-client pipe** — §3.1's three follow-ups.
|
||||||
|
- **A GL or DX12 fallback** — §2.1. A machine without Vulkan gets the library and no develop
|
||||||
|
path, which is what it gets on Linux too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The build script
|
||||||
|
|
||||||
|
`docker/windows/build.sh`, in the shape of the Android one and with the same rules:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
Release only, with `CARGO_TARGET_DIR` inside the workspace so the CI cache key
|
||||||
|
(`windows-${{ hashFiles('**/Cargo.lock') }}`) covers it. The whole workspace is *not* built for
|
||||||
|
the target: `darkroom-android` cannot be, and the examples that need a display or a catalog on
|
||||||
|
disk have nothing to run against. `cargo clippy --target x86_64-pc-windows-gnu -p darkroom-desktop`
|
||||||
|
is worth running in the same job, because the `cfg(windows)` branches from §3 are otherwise
|
||||||
|
never linted — the Linux job cannot see them.
|
||||||
|
|
||||||
|
Not `cargo test --target x86_64-pc-windows-gnu`: the test binaries would be Windows executables,
|
||||||
|
and running them means Wine. §6 does that for exactly one binary, deliberately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The installer
|
||||||
|
|
||||||
|
[`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi), compiled by `makensis` on
|
||||||
|
the runner into `DarkRoom-<version>-x86_64-setup.exe`. `package.sh` passes the version in
|
||||||
|
(`/DVERSION=…`, from `tools/set-version.sh`'s single source, the workspace `Cargo.toml`) and refuses
|
||||||
|
to run if any `models/face/*.onnx` is smaller than 100 KB — the LFS-pointer guard every other
|
||||||
|
packager carries, for the reason [distribution.md §1](distribution.md) gives.
|
||||||
|
|
||||||
|
### 5.1 Per-user, not per-machine
|
||||||
|
|
||||||
|
Install to `$LOCALAPPDATA\Programs\DarkRoom`, register the uninstaller under
|
||||||
|
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom`, `RequestExecutionLevel user`.
|
||||||
|
No UAC prompt, no `Program Files`, no writes outside the user's profile. This is the shape VS Code's
|
||||||
|
"User Installer" and most Electron applications use, and it is right for this project for two
|
||||||
|
reasons: an unsigned installer that also asks for administrator rights is the most alarming thing
|
||||||
|
Windows can show a user (§5.4), and a per-user install means the application's own data directories
|
||||||
|
(§3.2) and its binaries are governed by the same account, which is what NFR-SEC-5's
|
||||||
|
"the user's own hardware" means on a shared machine.
|
||||||
|
|
||||||
|
### 5.2 What it puts on disk
|
||||||
|
|
||||||
|
```
|
||||||
|
$LOCALAPPDATA\Programs\DarkRoom\
|
||||||
|
darkroom.exe
|
||||||
|
models\
|
||||||
|
scrfd_500m_640.onnx scrfd_2.5g_640.onnx scrfd_10g_640.onnx arcface_mbf_b1.onnx
|
||||||
|
yolo26s-sem-ade20k.onnx yolo26s-sem-ade20k.classes.json categories.txt
|
||||||
|
LICENSE
|
||||||
|
uninstall.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a Start Menu shortcut, and nothing on the desktop unless the user ticks it. The models are
|
||||||
|
the same seven files the APK bundles and the PKGBUILD installs; `models\` beside the executable is
|
||||||
|
where §3.2's lookup finds them. **No `LICENSE` yet**: the repository has no licence file at its
|
||||||
|
root (the Arch package points at the system's shared GPL text), so the installer has no licence
|
||||||
|
page until one is added — a one-file change, and the `.nsi` says where the page then goes. The face weights carry the research-only grant that
|
||||||
|
[faces.md §2](faces.md) records, and this channel changes nothing about that: the installer is
|
||||||
|
for the author's own machines until §2.2a's caveat is resolved, exactly as the APK is.
|
||||||
|
|
||||||
|
### 5.3 Uninstall
|
||||||
|
|
||||||
|
Removes the install directory, the shortcut and the registry key. **Does not touch
|
||||||
|
`%LOCALAPPDATA%\darkroom` or `%APPDATA%\darkroom`** — the catalog, the thumbnails, the face
|
||||||
|
index, the settings. An uninstaller that deletes a library index the user spent two hours building
|
||||||
|
is the kind of destructive default FR-CULL-12 and NFR-SEC-5's "disabling deletes nothing" both
|
||||||
|
argue against. The uninstaller says so on its one page, and names the two directories so a user who
|
||||||
|
does want them gone knows where they are.
|
||||||
|
|
||||||
|
### 5.4 Signing, and the warning that results from not doing it
|
||||||
|
|
||||||
|
An unsigned installer triggers SmartScreen's "Windows protected your PC" interstitial, dismissable
|
||||||
|
through "More info → Run anyway". Signing needs an Authenticode certificate, which is paid and
|
||||||
|
identity-verified; from Linux the signing itself is `osslsigncode`, which is why it is in the
|
||||||
|
container image, but there is no certificate to give it. **This spec ships unsigned** and the
|
||||||
|
release notes say what the interstitial looks like. An OV certificate is a cost decision to make
|
||||||
|
if this channel ever has a user who is not the author; an EV one buys instant reputation and costs
|
||||||
|
a hardware token. Neither is a build problem.
|
||||||
|
|
||||||
|
The APK went through the same sequence — [android-signing.md](android-signing.md) records a
|
||||||
|
debug-signed build becoming a release-signed one when it mattered — and this channel should be
|
||||||
|
allowed to do the same.
|
||||||
|
|
||||||
|
### 5.5 What NSIS is chosen over
|
||||||
|
|
||||||
|
WiX produces an MSI, which is what enterprise deployment tooling wants and what nobody deploying
|
||||||
|
a photo editor to their own laptop cares about; its Linux story is `wixl` from msitools, which is
|
||||||
|
real but thinly used. Inno Setup runs only under Wine. NSIS is scriptable in plain text, builds
|
||||||
|
natively on Linux, produces a single self-contained `.exe`, and the script for §5.2 is under a
|
||||||
|
hundred lines. It is the conventional answer for exactly this situation.
|
||||||
|
|
||||||
|
One choice inside NSIS: `Target amd64-unicode`, a 64-bit installer rather than the default 32-bit
|
||||||
|
stub. The application is x86_64 only so nothing is lost, and it is what lets §6's install test run
|
||||||
|
under a 64-bit-only Wine — the 32-bit stub needs an i386 multiarch Wine and dies loading the WoW64
|
||||||
|
`ntdll` without one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verifying without Windows
|
||||||
|
|
||||||
|
This is the part to be honest about. The runner has no Windows, no GPU it can hand to a Windows
|
||||||
|
process, and no display. What *can* be checked, in increasing cost and decreasing certainty:
|
||||||
|
|
||||||
|
| Check | How | What it proves |
|
||||||
|
|---|---|---|
|
||||||
|
| **It links** | the build succeeds | Every `cfg(windows)` branch compiles; no `unix`-only symbol leaked past a `cfg` |
|
||||||
|
| **It is a Windows executable** | `file darkroom.exe` reports PE32+; `x86_64-w64-mingw32-objdump -p` lists the DLLs it imports and none are MinGW's | The static-runtime flags in §2 held |
|
||||||
|
| **It starts** | `wine64 darkroom.exe --version` exits 0 and prints the version | The CRT, the resource block and `main` are sound; paths in §3.2 resolve (Wine sets `LOCALAPPDATA`) |
|
||||||
|
| **The installer runs** | `wine64 DarkRoom-setup.exe /S` then the install directory exists with the eight files, and `wine64 uninstall.exe /S` removes it | The NSIS script's file list, sections and uninstaller are right |
|
||||||
|
| **It draws a window** | `xvfb-run wine64 darkroom.exe` with `SLINT_WGPU_CPU` and a lavapipe ICD exposed through `winevulkan` | That Slint's winit backend initialises on Win32 — and this is where the chain gets long enough that a failure says more about Wine than about DarkRoom |
|
||||||
|
|
||||||
|
The first four are the CI gate. The fifth is worth trying once by hand and not putting in CI:
|
||||||
|
it needs `winevulkan` to find a host ICD, `xvfb`, and a Wine prefix warmed up in the container,
|
||||||
|
and every one of those is a moving part that has nothing to do with whether the application works
|
||||||
|
on Windows.
|
||||||
|
|
||||||
|
`--version` exists for this — a smoke test needs an exit that opens no window and touches no
|
||||||
|
directory — and it is answered before the logger and the crash hook install, so it proves the CRT
|
||||||
|
and the resource block and nothing above them.
|
||||||
|
|
||||||
|
**One more row the table missed:** the Start Menu shortcut. `CreateShortcut` is `IShellLink`,
|
||||||
|
which does nothing under a headless Wine while the `CreateDirectory` beside it succeeds, so an
|
||||||
|
installer that installs and uninstalls cleanly here can still have a broken shortcut. That row is
|
||||||
|
on Windows only.
|
||||||
|
|
||||||
|
**What none of this proves:** that wgpu opens a Vulkan device on a real driver, that a 6000-px
|
||||||
|
render completes, that fonts are found, that the secret store round-trips. Those are a person with
|
||||||
|
a Windows machine, once per release, until there is a Windows runner — and a self-hosted Windows
|
||||||
|
act_runner is how that would be done, not a cloud service. The release notes for the first build
|
||||||
|
say which of these were checked and on what.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The CI job
|
||||||
|
|
||||||
|
A fourth leg of [`build-and-test.yml`](../.gitea/workflows/build-and-test.yml), beside desktop,
|
||||||
|
Android and traceability:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
windows-image:
|
||||||
|
uses: ./.gitea/workflows/windows-image.yml # same shape as android-image.yml
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: Windows (x86_64, cross)
|
||||||
|
needs: windows-image
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
|
||||||
|
steps:
|
||||||
|
- checkout, LFS pull # copied from the desktop leg
|
||||||
|
- cache: ~/.cargo, target # key: windows-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
- docker/windows/build.sh # cargo build + clippy, --target x86_64-pc-windows-gnu
|
||||||
|
- smoke: file, objdump, wine64 --version # §6 rows 1–3
|
||||||
|
- docker/windows/package.sh # LFS guard, makensis
|
||||||
|
- smoke: wine64 setup.exe /S; ls; uninstall # §6 row 4
|
||||||
|
- upload artefact: DarkRoom-*-setup.exe # on tags only, like the APK
|
||||||
|
```
|
||||||
|
|
||||||
|
Same gotchas as the Android leg, which its comments already record: the host has no Node, so the
|
||||||
|
checkout is plain `git`; workflow inputs arrive as strings; the image build needs the host Docker
|
||||||
|
daemon and runs outside a container. None of that is new.
|
||||||
|
|
||||||
|
**Cost.** A cold build of the whole graph for a second target is roughly the desktop leg again —
|
||||||
|
tract, Slint's compiler, wgpu — so with the cargo cache warm it is minutes and cold it is the
|
||||||
|
better part of half an hour. Worth noting because the runner is one machine and the legs run in
|
||||||
|
parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Requirements
|
||||||
|
|
||||||
|
Three, added to [requirements.md §3.8](requirements.md) under a `#### Windows` heading beside the
|
||||||
|
Linux ones. Phrased to be testable, and each one is something §3 or §5 would otherwise leave as a
|
||||||
|
convention.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-1 — Known folders.** Configuration under `%APPDATA%\darkroom`; data, cache and
|
||||||
|
state under `%LOCALAPPDATA%\darkroom`. No file under the user's profile root and nothing relative
|
||||||
|
to the working directory. The directory *layout* beneath those roots is the same as under XDG, so
|
||||||
|
a library directory moves between platforms unchanged.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-2 — Installer.** A per-user installer that needs no elevation, registers an
|
||||||
|
uninstaller, and whose uninstaller removes what the installer wrote and nothing the application
|
||||||
|
wrote. Models are installed beside the executable and found there last, after the user's own
|
||||||
|
directories.
|
||||||
|
|
||||||
|
**FR-PLAT-WIN-3 — Built from Linux.** The Windows binary and its installer are produced by the
|
||||||
|
Linux CI from the same commit as every other channel, with no Windows machine in the build.
|
||||||
|
Verification on Windows is a release step, recorded per release, not a build step.
|
||||||
|
|
||||||
|
NFR-COMPAT-2's channel table in [distribution.md §1](distribution.md) gains a row. NFR-PORT-3 gets
|
||||||
|
its first real test, and the commit that closes §3.2 records the answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Order
|
||||||
|
|
||||||
|
1. `--version` in `main.rs`, and the `.cargo/config.toml` target block. Trivial, and the smoke
|
||||||
|
test in §6 needs both before anything else can be measured.
|
||||||
|
2. `rustup target add x86_64-pc-windows-gnu`, `pacman -S mingw-w64-gcc`, and a first
|
||||||
|
`cargo build --target …` on the developer machine — **before the container exists**, because
|
||||||
|
the list in §3.2 is a reading of the source and the compiler's list will be longer. Fix the
|
||||||
|
`cfg` fallout as it appears. This is the afternoon that decides whether §1's optimism holds.
|
||||||
|
3. §3.2 items 1–4, each its own commit, each stating which NFR-PORT interface it implemented.
|
||||||
|
4. §3.2 item 6 — the resource block — and the NSIS script; `makensis` by hand; `wine64 setup.exe /S`
|
||||||
|
by hand. Now there is an artefact.
|
||||||
|
5. The container, the image workflow, the CI leg. Only after 4 works locally, for the same reason
|
||||||
|
the Android image was reproduced from the tree after it had lived on one laptop.
|
||||||
|
6. A build on a real Windows machine, and a note in the release saying what was checked.
|
||||||
|
|
||||||
|
Steps 1–2 are cheap and either confirm this document or replace §3.2 with the true list. Nothing
|
||||||
|
past step 2 should be started on the strength of this document alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Report · 2026-09-12
|
||||||
|
|
||||||
|
Steps 1, 2 and 4 run, in the container rather than on the developer machine, because the
|
||||||
|
container was the cheaper way to get a pinned MinGW and a Wine that could be thrown away.
|
||||||
|
|
||||||
|
| §6 row | Result |
|
||||||
|
|---|---|
|
||||||
|
| It links | Yes, first attempt once the link flags were right. 115 MB, `PE32+ … (GUI)`. Two dead-code warnings, both `cfg`-shadowed constants, fixed. |
|
||||||
|
| It is a Windows executable | 26 imports, all Windows system DLLs. No MinGW runtime. `.rsrc` carries `PRODUCTVERSION 0,12,0,0`, `ProductName DarkRoom`, the icon. |
|
||||||
|
| It starts | `wine darkroom-desktop.exe --version` → `darkroom-desktop 0.12.0`, exit 0, 0.1 s. |
|
||||||
|
| The installer runs | `makensis` → 105 MB. `/S` installs the exe and seven models to `AppData\Local\Programs\DarkRoom`, writes the `HKCU` uninstall key; the installed exe runs; `uninstall.exe /S` removes directory and key. Shortcut unverifiable (§6). |
|
||||||
|
| It draws a window | Not attempted. |
|
||||||
|
|
||||||
|
**What the first draft got wrong**, kept in place above with a note rather than rewritten, because
|
||||||
|
the reasoning that produced each mistake is the thing a reader will otherwise repeat:
|
||||||
|
|
||||||
|
1. The `--whole-archive -lwinpthread` link flag (§2) — breaks the link and was never needed.
|
||||||
|
2. No host C compiler in the image (§2) — build scripts are host binaries.
|
||||||
|
3. Debian bookworm's Wine (§2) — lacks `bcryptprimitives.dll`, which rustc's std imports.
|
||||||
|
4. A `cfg(windows)` on the `winresource` build-dependency (§3.2 item 6) — evaluated against the
|
||||||
|
host, so the crate was silently absent from the cross-build.
|
||||||
|
|
||||||
|
And two things it did not know to say: NSIS's default stub is 32-bit (§5.5), and `CreateShortcut`
|
||||||
|
cannot be verified headless (§6).
|
||||||
|
|
||||||
|
**Closed since**, same day: all four §3.2 items (each says how), a `LICENSE` at the repository
|
||||||
|
root so the installer has its licence page, and §7's CI leg — `windows-image.yml` and the
|
||||||
|
`windows` job, every step of which was run by hand in the same container first. The Windows
|
||||||
|
target is also linted now, with `cargo clippy --target x86_64-pc-windows-gnu -- -D warnings` in
|
||||||
|
that job, which is the only place the `cfg(windows)` branches are ever compiled by CI.
|
||||||
|
|
||||||
|
**What the first real Windows run has to check**, in order, because Wine cannot: that a Vulkan
|
||||||
|
device opens on a real driver and a render completes; that fonts are found; that Credential
|
||||||
|
Manager round-trips a sign-in and the browser opens for Login Flow v2; that the Start Menu
|
||||||
|
shortcut exists; and that text is sharp on a scaled display (§3.3). The release notes for the
|
||||||
|
first build should say which of these were checked and on what machine.
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.12.0
|
pkgver=0.12.1
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
; DarkRoom — Windows installer
|
||||||
|
;
|
||||||
|
; Built by docker/windows/package.sh with makensis on Linux; see docs/windows.md
|
||||||
|
; §5 for every decision below. Nothing here is Windows-specific to *write* —
|
||||||
|
; NSIS has always compiled on POSIX hosts.
|
||||||
|
;
|
||||||
|
; makensis -DVERSION=0.12.0 -DSTAGE=/path/to/staging -DOUT=/path/to/setup.exe darkroom.nsi
|
||||||
|
;
|
||||||
|
; STAGE holds exactly what §5.2 installs: darkroom.exe, models\ and LICENSE.
|
||||||
|
; package.sh assembles it and applies the LFS-pointer guard before this runs.
|
||||||
|
|
||||||
|
; A 64-bit installer, not NSIS's default 32-bit stub. The application is
|
||||||
|
; x86_64 only, so nothing is lost; and it means the smoke test runs under a
|
||||||
|
; 64-bit-only Wine rather than needing an i386 multiarch: the 32-bit stub
|
||||||
|
; cannot load the WoW64 ntdll there and dies before showing a page.
|
||||||
|
Target amd64-unicode
|
||||||
|
Unicode true
|
||||||
|
SetCompressor /SOLID lzma
|
||||||
|
|
||||||
|
!ifndef VERSION
|
||||||
|
!error "pass -DVERSION=x.y.z"
|
||||||
|
!endif
|
||||||
|
!ifndef STAGE
|
||||||
|
!error "pass -DSTAGE=<staging directory>"
|
||||||
|
!endif
|
||||||
|
!ifndef OUT
|
||||||
|
!error "pass -DOUT=<installer path>"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!define NAME "DarkRoom"
|
||||||
|
!define PUBLISHER "Duncan Tourolle"
|
||||||
|
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${NAME}"
|
||||||
|
|
||||||
|
Name "${NAME} ${VERSION}"
|
||||||
|
OutFile "${OUT}"
|
||||||
|
|
||||||
|
; Per-user, no elevation (docs/windows.md §5.1). An unsigned installer that
|
||||||
|
; also asks for administrator rights is the most alarming thing Windows can
|
||||||
|
; show, and a per-user install keeps the binaries under the same account as
|
||||||
|
; the library the application builds.
|
||||||
|
RequestExecutionLevel user
|
||||||
|
InstallDir "$LOCALAPPDATA\Programs\${NAME}"
|
||||||
|
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation"
|
||||||
|
|
||||||
|
; The resource block the executable itself carries (`winresource` in
|
||||||
|
; darkroom-desktop's build.rs) is what Explorer shows for the application;
|
||||||
|
; this is what it shows for the installer.
|
||||||
|
VIProductVersion "${VERSION}.0"
|
||||||
|
VIAddVersionKey "ProductName" "${NAME}"
|
||||||
|
VIAddVersionKey "ProductVersion" "${VERSION}"
|
||||||
|
VIAddVersionKey "FileVersion" "${VERSION}"
|
||||||
|
VIAddVersionKey "CompanyName" "${PUBLISHER}"
|
||||||
|
VIAddVersionKey "LegalCopyright" "GPL-3.0-or-later"
|
||||||
|
VIAddVersionKey "FileDescription" "${NAME} installer"
|
||||||
|
|
||||||
|
!include "MUI2.nsh"
|
||||||
|
!define MUI_ABORTWARNING
|
||||||
|
; The GPL, shown rather than linked, for the same reason faces.md §2.2 gives
|
||||||
|
; for the model grant: the moment it can still change a decision.
|
||||||
|
!insertmacro MUI_PAGE_LICENSE "${STAGE}\LICENSE"
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
!insertmacro MUI_UNPAGE_CONFIRM
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES
|
||||||
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
Section "DarkRoom" SecMain
|
||||||
|
SectionIn RO
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
File "${STAGE}\darkroom.exe"
|
||||||
|
File "${STAGE}\LICENSE"
|
||||||
|
|
||||||
|
; The seven model files (four face, three scene), beside the executable,
|
||||||
|
; which is where `library::system_face_models_dirs` looks on Windows —
|
||||||
|
; last, after the user's own directories, exactly as /usr/share is on Linux.
|
||||||
|
SetOutPath "$INSTDIR\models"
|
||||||
|
File /r "${STAGE}\models\*"
|
||||||
|
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; Add/Remove Programs. HKCU, to match the per-user install.
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${NAME}"
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${PUBLISHER}"
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\darkroom.exe"
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||||
|
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
|
||||||
|
WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1
|
||||||
|
WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1
|
||||||
|
|
||||||
|
CreateDirectory "$SMPROGRAMS\${NAME}"
|
||||||
|
CreateShortcut "$SMPROGRAMS\${NAME}\${NAME}.lnk" "$INSTDIR\darkroom.exe"
|
||||||
|
CreateShortcut "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk" "$INSTDIR\uninstall.exe"
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Desktop shortcut" SecDesktop
|
||||||
|
CreateShortcut "$DESKTOP\${NAME}.lnk" "$INSTDIR\darkroom.exe"
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
; Off by default: a desktop icon is the user's to ask for.
|
||||||
|
Function .onInit
|
||||||
|
SectionSetFlags ${SecDesktop} 0
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
; What the installer wrote, and nothing the application wrote
|
||||||
|
; (docs/windows.md §5.3): %LOCALAPPDATA%\darkroom and %APPDATA%\darkroom —
|
||||||
|
; the catalog, the thumbnails, the face index, the settings — stay. An
|
||||||
|
; uninstaller that deletes a library index the user spent two hours
|
||||||
|
; building is the destructive default this project argues against
|
||||||
|
; everywhere else, so the confirm page says where those directories are.
|
||||||
|
Delete "$INSTDIR\darkroom.exe"
|
||||||
|
Delete "$INSTDIR\LICENSE"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
RMDir /r "$INSTDIR\models"
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
|
||||||
|
Delete "$SMPROGRAMS\${NAME}\${NAME}.lnk"
|
||||||
|
Delete "$SMPROGRAMS\${NAME}\Uninstall ${NAME}.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\${NAME}"
|
||||||
|
Delete "$DESKTOP\${NAME}.lnk"
|
||||||
|
|
||||||
|
DeleteRegKey HKCU "${UNINST_KEY}"
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Function un.onInit
|
||||||
|
MessageBox MB_OKCANCEL|MB_ICONINFORMATION \
|
||||||
|
"This removes the ${NAME} program.$\r$\n$\r$\nYour library index and settings are kept, in:$\r$\n $LOCALAPPDATA\darkroom$\r$\n $APPDATA\darkroom" \
|
||||||
|
/SD IDOK IDOK done
|
||||||
|
Abort
|
||||||
|
done:
|
||||||
|
FunctionEnd
|
||||||
@@ -24,6 +24,11 @@ x11rb.workspace = true
|
|||||||
wayland-client.workspace = true
|
wayland-client.workspace = true
|
||||||
wayland-protocols.workspace = true
|
wayland-protocols.workspace = true
|
||||||
|
|
||||||
|
# The same crate on Windows: its `v1` features include the Credential Manager
|
||||||
|
# backend, and `secrets.rs` is one implementation over both.
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
keyring.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android-native-keyring-store.workspace = true
|
android-native-keyring-store.workspace = true
|
||||||
keyring-core.workspace = true
|
keyring-core.workspace = true
|
||||||
|
|||||||
@@ -106,12 +106,7 @@ pub fn state_dir() -> PathBuf {
|
|||||||
if let Some(d) = STATE_DIR.get() {
|
if let Some(d) = STATE_DIR.get() {
|
||||||
return d.clone();
|
return d.clone();
|
||||||
}
|
}
|
||||||
std::env::var_os("XDG_STATE_HOME")
|
crate::dirs::base_dir(crate::dirs::Base::State)
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
|
|
||||||
})
|
|
||||||
.join("darkroom")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where crash records are written.
|
/// Where crash records are written.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
//! [`redact::redact`] is public so the record gets the same treatment as the
|
//! [`redact::redact`] is public so the record gets the same treatment as the
|
||||||
//! lines around it.
|
//! lines around it.
|
||||||
|
|
||||||
|
pub mod bundle;
|
||||||
pub mod redact;
|
pub mod redact;
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
//! TRACES: NFR-OPS-1 | NFR-SEC-2 | NFR-SEC-4 | NFR-SEC-5
|
||||||
|
//! The diagnostics bundle: one file, shown before it is written.
|
||||||
|
//!
|
||||||
|
//! NFR-OPS-1's second sentence asks for "a one-click diagnostics bundle" of
|
||||||
|
//! the log, the schema version, the GPU and driver, and the app version —
|
||||||
|
//! "with an explicit preview-and-consent step before anything leaves the
|
||||||
|
//! device". The log and the crash records have existed since the sink and the
|
||||||
|
//! panic hook landed; what did not exist was any way to hand them over that
|
||||||
|
//! was not `adb pull` and a knowledge of where the state directory is.
|
||||||
|
//!
|
||||||
|
//! # What "leaves the device" means here, and why the step is where it is
|
||||||
|
//!
|
||||||
|
//! Nothing in this module sends anything. There is no endpoint, no upload,
|
||||||
|
//! no queue — `crash.rs` says why, and the reason holds: a transport built
|
||||||
|
//! ahead of the consent is the shape of thing that gets switched on by
|
||||||
|
//! default. What the bundle does is write **one text file** to a place the
|
||||||
|
//! user can find, so that *they* can attach it to a message. That is the
|
||||||
|
//! moment it leaves, and it is theirs.
|
||||||
|
//!
|
||||||
|
//! So the consent step guards the write, not a send. [`Bundle::gather`] reads
|
||||||
|
//! everything into memory and touches no file; [`Bundle::preview`] says what
|
||||||
|
//! was gathered, how much of it, and what was taken out; and only
|
||||||
|
//! [`Bundle::write_to`] puts bytes on disk. A user who reads the preview and
|
||||||
|
//! closes the panel has changed nothing anywhere. The interface has to keep
|
||||||
|
//! those as two presses, and does.
|
||||||
|
//!
|
||||||
|
//! # One file, and text
|
||||||
|
//!
|
||||||
|
//! A directory is a thing to zip and a zip is a thing to explain. A single
|
||||||
|
//! `.txt` opens on every platform the user might be sitting at, pastes into
|
||||||
|
//! an issue, and can be read *in the preview* as exactly the bytes that will
|
||||||
|
//! be written — which is what makes the consent honest rather than a summary
|
||||||
|
//! of something else. The sections are separated by a fence no log line can
|
||||||
|
//! produce, so a reader can find the seams and a tool could split them.
|
||||||
|
//!
|
||||||
|
//! # Redacted again, on the way in
|
||||||
|
//!
|
||||||
|
//! Every line here has already been through a redaction: the log at its sink
|
||||||
|
//! ([`super::redact`]), the crash records when they were composed
|
||||||
|
//! ([`crate::crash::redact`]). The bundle runs the *blunter* of the two over
|
||||||
|
//! all of it regardless. The log's own rule keeps file paths, because a path
|
||||||
|
//! in the log a developer reads over `adb` is context; the bundle is a file
|
||||||
|
//! meant to be attached to a public report by someone who may not read it
|
||||||
|
//! first, and a directory listing of their photographs is the thing the
|
||||||
|
//! preview exists to prevent. Redacting twice costs nothing and makes the
|
||||||
|
//! promise in the preview a property of this module rather than of the
|
||||||
|
//! modules upstream having done their part.
|
||||||
|
//!
|
||||||
|
//! NFR-SEC-5 needs no work here and gets a tag anyway: no face data can reach
|
||||||
|
//! this bundle because nothing in `dr-plat` can see a catalog, an embedding
|
||||||
|
//! or a crop. The tag records that the property was checked, not that it was
|
||||||
|
//! built.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::crash;
|
||||||
|
|
||||||
|
/// The facts the requirement names beside the log, supplied by the caller
|
||||||
|
/// because none of them is this crate's to know: the running version, the
|
||||||
|
/// catalog schema, and what the GPU said it was.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Facts {
|
||||||
|
pub app_version: String,
|
||||||
|
pub schema_version: i64,
|
||||||
|
/// Adapter, backend, and driver, in whatever words the GPU gave.
|
||||||
|
pub graphics: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One section of the bundle: a named piece of text and how big it is.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Item {
|
||||||
|
/// What the section is called in the file — a filename, or `manifest`.
|
||||||
|
pub name: String,
|
||||||
|
pub lines: usize,
|
||||||
|
pub bytes: usize,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the bundle would write, gathered and held in memory.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Bundle {
|
||||||
|
when: u64,
|
||||||
|
items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fence between sections. Long enough that no redacted log line is it.
|
||||||
|
const FENCE: &str = "================================================================";
|
||||||
|
|
||||||
|
impl Bundle {
|
||||||
|
/// Gather the log, its rotated predecessor, and every crash record on
|
||||||
|
/// disk, redacted. Reads files; writes none.
|
||||||
|
pub fn gather(facts: &Facts) -> Self {
|
||||||
|
let log = super::log_path();
|
||||||
|
let previous = log
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.map(|dir| dir.join(format!("{}.1", super::FILE_NAME)));
|
||||||
|
Self::from_sources(
|
||||||
|
facts,
|
||||||
|
now(),
|
||||||
|
log.as_deref(),
|
||||||
|
previous.as_deref(),
|
||||||
|
&crash::records(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gathering, with every path handed in — so a test can build a
|
||||||
|
/// bundle from a directory of its own without touching the process-wide
|
||||||
|
/// log.
|
||||||
|
pub fn from_sources(
|
||||||
|
facts: &Facts,
|
||||||
|
when: u64,
|
||||||
|
log: Option<&Path>,
|
||||||
|
previous: Option<&Path>,
|
||||||
|
crashes: &[PathBuf],
|
||||||
|
) -> Self {
|
||||||
|
let mut items = vec![manifest(facts, when, log)];
|
||||||
|
for path in [log, previous].into_iter().flatten() {
|
||||||
|
if let Some(item) = read_item(path) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in crashes {
|
||||||
|
if let Some(item) = read_item(path) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { when, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn items(&self) -> &[Item] {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name the file is written under: stamped, so two bundles from one
|
||||||
|
/// machine do not overwrite each other, and matching the crash records'
|
||||||
|
/// convention so a directory of both sorts by time.
|
||||||
|
pub fn file_name(&self) -> String {
|
||||||
|
format!("darkroom-diagnostics-{}.txt", self.when)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the user reads before deciding. Every claim in it is a property
|
||||||
|
/// of [`Self::render`], which is the same items in the same order.
|
||||||
|
pub fn preview(&self, destination: &Path) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(out, "Nothing has been written yet. This is what would be:");
|
||||||
|
let _ = writeln!(out);
|
||||||
|
for item in &self.items {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" {:<32} {:>7} lines {}",
|
||||||
|
item.name,
|
||||||
|
item.lines,
|
||||||
|
size(item.bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let total: usize = self.items.iter().map(|i| i.bytes).sum();
|
||||||
|
let _ = writeln!(out);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"Credentials, tokens, and anything that reads as a file path or a web \
|
||||||
|
address have been replaced in every line; the names of Rust source \
|
||||||
|
files in a backtrace are kept. No photograph, thumbnail, face or \
|
||||||
|
person is in it — nothing here can read the catalog."
|
||||||
|
);
|
||||||
|
let _ = writeln!(out);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"Saving writes one text file, {} ({}), to:\n {}\n\
|
||||||
|
It is sent nowhere. Attaching it to a report is yours to do.",
|
||||||
|
self.file_name(),
|
||||||
|
size(total),
|
||||||
|
destination.display()
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole bundle as the text that would be written.
|
||||||
|
pub fn render(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for item in &self.items {
|
||||||
|
let _ = writeln!(out, "{FENCE}");
|
||||||
|
let _ = writeln!(out, "== {}", item.name);
|
||||||
|
let _ = writeln!(out, "{FENCE}");
|
||||||
|
out.push_str(&item.text);
|
||||||
|
if !item.text.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the bundle into `dir`, creating it, and return the file's path.
|
||||||
|
/// The only function here that writes.
|
||||||
|
pub fn write_to(&self, dir: &Path) -> io::Result<PathBuf> {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
let path = dir.join(self.file_name());
|
||||||
|
fs::write(&path, self.render())?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a bundle is written when nobody says otherwise.
|
||||||
|
///
|
||||||
|
/// The desktop gets the downloads directory — the one place a file can be
|
||||||
|
/// put that every "attach a file" dialog opens on, and that the user already
|
||||||
|
/// knows how to find and how to clear. `XDG_DOWNLOAD_DIR` is only in
|
||||||
|
/// `user-dirs.dirs`, which nothing here parses, so the conventional name
|
||||||
|
/// under the home directory stands in for it; failing a home, the state
|
||||||
|
/// directory, which always exists by the time this can be called.
|
||||||
|
///
|
||||||
|
/// Android has no downloads directory an app may write without a permission
|
||||||
|
/// prompt, and the state directory there *is* the externally readable one —
|
||||||
|
/// `adb pull` and the files app both reach it — so it is used directly.
|
||||||
|
pub fn default_dir() -> PathBuf {
|
||||||
|
if cfg!(target_os = "android") {
|
||||||
|
return crate::state::state_dir();
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(|home| PathBuf::from(home).join("Downloads"))
|
||||||
|
.filter(|d| d.is_dir())
|
||||||
|
.unwrap_or_else(crate::state::state_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The section that carries what the requirement asks for beside the log.
|
||||||
|
/// First, so the version is the first thing anyone reading the file sees.
|
||||||
|
fn manifest(facts: &Facts, when: u64, log: Option<&Path>) -> Item {
|
||||||
|
let mut text = String::new();
|
||||||
|
let _ = writeln!(text, "darkroom {}", facts.app_version);
|
||||||
|
let _ = writeln!(text, "catalog schema {}", facts.schema_version);
|
||||||
|
let _ = writeln!(text, "graphics {}", facts.graphics);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
"platform {} {}",
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH
|
||||||
|
);
|
||||||
|
let _ = writeln!(text, "gathered {when} (unix seconds, UTC)");
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
"log {}",
|
||||||
|
match log {
|
||||||
|
// The basename only: the directory is a path, and the manifest
|
||||||
|
// is held to the same rule as everything under it.
|
||||||
|
Some(p) => p.file_name().map_or_else(
|
||||||
|
|| "present".to_string(),
|
||||||
|
|n| n.to_string_lossy().into_owned()
|
||||||
|
),
|
||||||
|
None => "none — this session logs to the console only".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
text,
|
||||||
|
"log cap {} bytes per file, {} rotated file kept",
|
||||||
|
super::MAX_FILE_BYTES,
|
||||||
|
super::RETAINED_GENERATIONS
|
||||||
|
);
|
||||||
|
item("manifest", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on disk as a redacted section, or nothing if it cannot be read —
|
||||||
|
/// an unreadable log is reported in the preview by its absence, which is the
|
||||||
|
/// truthful thing, rather than by a bundle that fails to build.
|
||||||
|
fn read_item(path: &Path) -> Option<Item> {
|
||||||
|
let raw = fs::read(path).ok()?;
|
||||||
|
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
let text: String = text
|
||||||
|
.lines()
|
||||||
|
.map(crash::redact)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
Some(item(&name, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item(name: &str, text: String) -> Item {
|
||||||
|
Item {
|
||||||
|
name: name.to_string(),
|
||||||
|
lines: text.lines().count(),
|
||||||
|
bytes: text.len(),
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(bytes: usize) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{} KB", bytes / 1024)
|
||||||
|
} else {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn facts() -> Facts {
|
||||||
|
Facts {
|
||||||
|
app_version: "0.12.0".into(),
|
||||||
|
schema_version: 14,
|
||||||
|
graphics: "Test GPU (VULKAN) driver 1.0".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-bundle-{name}-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the consent rests on: the preview describes the file,
|
||||||
|
/// and building the preview writes nothing.
|
||||||
|
#[test]
|
||||||
|
fn gathering_and_previewing_write_nothing() {
|
||||||
|
let d = dir("preview");
|
||||||
|
let log = d.join("darkroom.log");
|
||||||
|
fs::write(&log, "one\ntwo\n").unwrap();
|
||||||
|
let before = fs::read_dir(&d).unwrap().flatten().count();
|
||||||
|
|
||||||
|
let bundle = Bundle::from_sources(&facts(), 1_700_000_000, Some(&log), None, &[]);
|
||||||
|
let preview = bundle.preview(&d);
|
||||||
|
|
||||||
|
assert!(preview.contains("darkroom.log"), "{preview}");
|
||||||
|
assert!(preview.contains("2 lines"), "{preview}");
|
||||||
|
assert!(
|
||||||
|
preview.contains("Nothing has been written yet"),
|
||||||
|
"{preview}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(&d).unwrap().flatten().count(),
|
||||||
|
before,
|
||||||
|
"the preview put a file on disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And the write is one file, named as the preview said, holding the
|
||||||
|
/// sections the preview listed in the order it listed them.
|
||||||
|
#[test]
|
||||||
|
fn saving_writes_one_file_with_every_section() {
|
||||||
|
let d = dir("save");
|
||||||
|
let log = d.join("darkroom.log");
|
||||||
|
let previous = d.join("darkroom.log.1");
|
||||||
|
let crash = d.join("crash-1700000000-1.txt");
|
||||||
|
fs::write(&log, "current\n").unwrap();
|
||||||
|
fs::write(&previous, "older\n").unwrap();
|
||||||
|
fs::write(&crash, "panicked at library.rs:12\n").unwrap();
|
||||||
|
|
||||||
|
let bundle = Bundle::from_sources(
|
||||||
|
&facts(),
|
||||||
|
1_700_000_001,
|
||||||
|
Some(&log),
|
||||||
|
Some(&previous),
|
||||||
|
&[crash],
|
||||||
|
);
|
||||||
|
let out = d.join("out");
|
||||||
|
let written = bundle.write_to(&out).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
written.file_name().unwrap(),
|
||||||
|
"darkroom-diagnostics-1700000001.txt"
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read_dir(&out).unwrap().flatten().count(), 1);
|
||||||
|
let text = fs::read_to_string(&written).unwrap();
|
||||||
|
let names: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("== ")).collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[
|
||||||
|
"manifest",
|
||||||
|
"darkroom.log",
|
||||||
|
"darkroom.log.1",
|
||||||
|
"crash-1700000000-1.txt"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(text.contains("darkroom 0.12.0"), "{text}");
|
||||||
|
assert!(text.contains("catalog schema 14"), "{text}");
|
||||||
|
assert!(text.contains("Test GPU"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("library.rs:12"),
|
||||||
|
"a backtrace keeps its source names"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The redaction is this module's promise, not an assumption about the
|
||||||
|
/// files it read: a path or a secret that reached the log unredacted
|
||||||
|
/// still does not reach the bundle.
|
||||||
|
#[test]
|
||||||
|
fn a_path_in_the_log_does_not_reach_the_bundle() {
|
||||||
|
let d = dir("redact");
|
||||||
|
let log = d.join("darkroom.log");
|
||||||
|
fs::write(
|
||||||
|
&log,
|
||||||
|
"opened /home/someone/Photos/2024/wedding/IMG_0001.CR3\npassword=hunter2hunter2\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let bundle = Bundle::from_sources(&facts(), 1, Some(&log), None, &[]);
|
||||||
|
let text = bundle.render();
|
||||||
|
assert!(!text.contains("wedding"), "{text}");
|
||||||
|
assert!(!text.contains("hunter2"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("opened"),
|
||||||
|
"the rest of the line survives: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session logging to the console only still gets a bundle — the
|
||||||
|
/// manifest and the crash records — and the manifest says the log is
|
||||||
|
/// missing rather than the bundle failing to build.
|
||||||
|
#[test]
|
||||||
|
fn no_log_is_a_bundle_that_says_so() {
|
||||||
|
let bundle = Bundle::from_sources(&facts(), 1, None, None, &[]);
|
||||||
|
assert_eq!(bundle.items().len(), 1);
|
||||||
|
assert!(bundle.render().contains("console only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! TRACES: FR-PLAT-LIN-1 | FR-PLAT-WIN-1 | NFR-PORT-1
|
||||||
|
//! Where this application's files belong on this platform.
|
||||||
|
//!
|
||||||
|
//! One place for the rule, because it was in five. Each site read
|
||||||
|
//! `XDG_*_HOME` and fell back to `$HOME/.local/...` on its own, which is fine
|
||||||
|
//! on Linux and wrong everywhere else: Windows sets neither variable, so every
|
||||||
|
//! one of them degraded to a path relative to the working directory — for a
|
||||||
|
//! Start Menu launch, `C:\Windows\System32`. The callers keep their own
|
||||||
|
//! override (`set_state_dir`, `set_data_dir`), which is how Android names its
|
||||||
|
//! app-private directory; this answers the question those overrides do not.
|
||||||
|
//!
|
||||||
|
//! # The rules
|
||||||
|
//!
|
||||||
|
//! | | Config | Data | State |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | Unix | `$XDG_CONFIG_HOME` | `$XDG_DATA_HOME` | `$XDG_STATE_HOME` |
|
||||||
|
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
||||||
|
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
||||||
|
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
|
||||||
|
//!
|
||||||
|
//! then `darkroom` under each. Config roams on Windows and the rest does not,
|
||||||
|
//! which is the same split XDG makes between config and everything else, and
|
||||||
|
//! the reason `settings.json` is small and the thumbnail store is not.
|
||||||
|
//!
|
||||||
|
//! A relative value is ignored rather than resolved: the XDG specification
|
||||||
|
//! says so, and the alternative is a `darkroom/` directory wherever the app
|
||||||
|
//! was launched from. With nothing usable set at all the answer is the
|
||||||
|
//! temporary directory — a container or a service unit with no home — because
|
||||||
|
//! writing a log into `/tmp` is a poor outcome and refusing to write one, on
|
||||||
|
//! exactly the machine nobody is sitting in front of, is a worse one.
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Which kind of directory. The distinction is the one every platform makes:
|
||||||
|
/// what the user edits, what the application builds, and what it records.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Base {
|
||||||
|
/// Settings and accounts. Small, and the user may back it up or edit it.
|
||||||
|
Config,
|
||||||
|
/// Catalogs, thumbnails, models. Large, rebuildable, never edited by hand.
|
||||||
|
Data,
|
||||||
|
/// Logs and crash records. Survives a cache sweep; not configuration.
|
||||||
|
State,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory of this kind on this machine, ending in `darkroom`.
|
||||||
|
///
|
||||||
|
/// Not created here: whoever writes into it creates it, so that merely asking
|
||||||
|
/// leaves nothing behind.
|
||||||
|
pub fn base_dir(kind: Base) -> PathBuf {
|
||||||
|
resolve(kind, |name| std::env::var_os(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directories a package may have installed shared data into, most specific
|
||||||
|
/// first, each ending in `darkroom`.
|
||||||
|
///
|
||||||
|
/// Unix: `$XDG_DATA_DIRS`, defaulting to `/usr/local/share:/usr/share` — what
|
||||||
|
/// the Arch package and the Flatpak install under. Windows: the directory the
|
||||||
|
/// executable is in, which is where the installer puts the models
|
||||||
|
/// (FR-PLAT-WIN-2). Android: none; the APK's copy is unpacked into the user
|
||||||
|
/// data directory instead, because an asset inside a package is not a path
|
||||||
|
/// anything can read from.
|
||||||
|
pub fn system_data_dirs() -> Vec<PathBuf> {
|
||||||
|
if cfg!(target_os = "android") {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
return std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|exe| exe.parent().map(Path::to_path_buf))
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let dirs = std::env::var("XDG_DATA_DIRS")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||||
|
dirs.split(':')
|
||||||
|
.filter(|d| !d.is_empty())
|
||||||
|
.map(|d| PathBuf::from(d).join("darkroom"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolution as a function of its inputs rather than of the process
|
||||||
|
/// environment, so it can be tested without `set_var` racing every other
|
||||||
|
/// test in the binary — and so both platforms' rules are tested on either.
|
||||||
|
fn resolve(kind: Base, env: impl Fn(&str) -> Option<OsString>) -> PathBuf {
|
||||||
|
let base = if cfg!(windows) {
|
||||||
|
windows_base(kind, &env)
|
||||||
|
} else {
|
||||||
|
xdg_base(kind, &env)
|
||||||
|
};
|
||||||
|
let dir = base.unwrap_or_else(std::env::temp_dir).join("darkroom");
|
||||||
|
match (kind, cfg!(windows)) {
|
||||||
|
// Data and state share `%LOCALAPPDATA%`; state gets its own level so
|
||||||
|
// a log and a catalog do not sit side by side.
|
||||||
|
(Base::State, true) => dir.join("state"),
|
||||||
|
_ => dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||||
|
let (var, under_home) = match kind {
|
||||||
|
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
||||||
|
Base::Data => ("XDG_DATA_HOME", ".local/share"),
|
||||||
|
Base::State => ("XDG_STATE_HOME", ".local/state"),
|
||||||
|
};
|
||||||
|
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn windows_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||||
|
let (var, under_profile) = match kind {
|
||||||
|
Base::Config => ("APPDATA", "AppData\\Roaming"),
|
||||||
|
Base::Data | Base::State => ("LOCALAPPDATA", "AppData\\Local"),
|
||||||
|
};
|
||||||
|
absolute(env(var)).or_else(|| absolute(env("USERPROFILE")).map(|p| p.join(under_profile)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A value only if it names an absolute path. `is_absolute` is the host's
|
||||||
|
/// notion, so a test of the Windows rule on Linux uses Unix-shaped paths;
|
||||||
|
/// what is tested is the variable and the suffix, which is the part that
|
||||||
|
/// was wrong.
|
||||||
|
fn absolute(value: Option<OsString>) -> Option<PathBuf> {
|
||||||
|
value
|
||||||
|
.filter(|v| Path::new(v).is_absolute())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
|
||||||
|
let map: HashMap<String, OsString> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), OsString::from(v)))
|
||||||
|
.collect();
|
||||||
|
move |name| map.get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xdg_honours_each_variable_and_defaults_under_home() {
|
||||||
|
let e = env(&[("XDG_CONFIG_HOME", "/etc/me"), ("HOME", "/home/someone")]);
|
||||||
|
assert_eq!(xdg_base(Base::Config, &e), Some(PathBuf::from("/etc/me")));
|
||||||
|
assert_eq!(
|
||||||
|
xdg_base(Base::Data, &e),
|
||||||
|
Some(PathBuf::from("/home/someone/.local/share"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
xdg_base(Base::State, &e),
|
||||||
|
Some(PathBuf::from("/home/someone/.local/state"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relative_value_is_ignored_rather_than_resolved() {
|
||||||
|
// The specification requires this, and the failure it prevents is a
|
||||||
|
// `darkroom/` directory appearing in whatever the working directory
|
||||||
|
// happened to be — including, on a desktop launcher, `/`.
|
||||||
|
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
|
||||||
|
assert_eq!(
|
||||||
|
xdg_base(Base::State, &e),
|
||||||
|
Some(PathBuf::from("/home/someone/.local/state"))
|
||||||
|
);
|
||||||
|
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_splits_config_from_the_rest() {
|
||||||
|
// Config roams, data and state do not. The variable is what was wrong
|
||||||
|
// before this module: neither XDG_* nor HOME exists on Windows.
|
||||||
|
let e = env(&[("APPDATA", "/r"), ("LOCALAPPDATA", "/l")]);
|
||||||
|
assert_eq!(windows_base(Base::Config, &e), Some(PathBuf::from("/r")));
|
||||||
|
assert_eq!(windows_base(Base::Data, &e), Some(PathBuf::from("/l")));
|
||||||
|
assert_eq!(windows_base(Base::State, &e), Some(PathBuf::from("/l")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_falls_back_to_the_profile() {
|
||||||
|
let e = env(&[("USERPROFILE", "/u")]);
|
||||||
|
assert_eq!(
|
||||||
|
windows_base(Base::Config, &e),
|
||||||
|
Some(PathBuf::from("/u").join("AppData\\Roaming"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
windows_base(Base::Data, &e),
|
||||||
|
Some(PathBuf::from("/u").join("AppData\\Local"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_set_is_still_absolute() {
|
||||||
|
// A container or a service unit: the answer is somewhere writable,
|
||||||
|
// never a relative path.
|
||||||
|
let dir = resolve(Base::State, env(&[]));
|
||||||
|
assert!(dir.is_absolute());
|
||||||
|
assert!(dir.ends_with("darkroom") || dir.ends_with("state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_kind_ends_in_the_application_name() {
|
||||||
|
let e = env(&[
|
||||||
|
("HOME", "/home/someone"),
|
||||||
|
("APPDATA", "/r"),
|
||||||
|
("LOCALAPPDATA", "/l"),
|
||||||
|
]);
|
||||||
|
for kind in [Base::Config, Base::Data, Base::State] {
|
||||||
|
let dir = resolve(kind, &e);
|
||||||
|
assert!(
|
||||||
|
dir.components().any(|c| c.as_os_str() == "darkroom"),
|
||||||
|
"{kind:?} resolved to {}",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// arrangement this crate exists to prevent.
|
// arrangement this crate exists to prevent.
|
||||||
pub mod crash;
|
pub mod crash;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
pub mod dirs;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
@@ -20,6 +21,7 @@ pub mod storage;
|
|||||||
pub mod volumes;
|
pub mod volumes;
|
||||||
|
|
||||||
pub use diagnostics::{Installed, LogFile};
|
pub use diagnostics::{Installed, LogFile};
|
||||||
|
pub use dirs::{base_dir, system_data_dirs, Base};
|
||||||
pub use display::{
|
pub use display::{
|
||||||
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
||||||
ProfileSource,
|
ProfileSource,
|
||||||
|
|||||||
@@ -97,13 +97,25 @@ pub trait SecretStore: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The service name entries are filed under.
|
/// The service name entries are filed under.
|
||||||
|
///
|
||||||
|
/// Used by the two stores that have somewhere to file them; the placeholder
|
||||||
|
/// below has nothing to name, and a Windows build otherwise warns here.
|
||||||
|
#[cfg(any(all(unix, not(target_os = "android")), windows, target_os = "android"))]
|
||||||
const SERVICE: &str = "DarkRoom";
|
const SERVICE: &str = "DarkRoom";
|
||||||
|
|
||||||
/// Secret Service implementation (GNOME Keyring, KWallet via ksecretd).
|
/// The `keyring`-backed store: Secret Service on Linux (GNOME Keyring,
|
||||||
#[cfg(all(unix, not(target_os = "android")))]
|
/// KWallet via ksecretd), Credential Manager on Windows.
|
||||||
|
///
|
||||||
|
/// One implementation for both because `keyring::Entry` is the same API over
|
||||||
|
/// either, and its `v1` feature set already includes the Windows backend. The
|
||||||
|
/// difference is in what "unavailable" means: a Linux desktop may have no
|
||||||
|
/// secrets daemon, which FR-NC-2 treats as a stated degraded mode, while
|
||||||
|
/// Credential Manager is always present — so on Windows `is_available` is a
|
||||||
|
/// probe that always succeeds, and that is correct rather than optimistic.
|
||||||
|
#[cfg(any(all(unix, not(target_os = "android")), windows))]
|
||||||
pub struct PlatformSecretStore;
|
pub struct PlatformSecretStore;
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "android")))]
|
#[cfg(any(all(unix, not(target_os = "android")), windows))]
|
||||||
impl PlatformSecretStore {
|
impl PlatformSecretStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
@@ -114,14 +126,14 @@ impl PlatformSecretStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "android")))]
|
#[cfg(any(all(unix, not(target_os = "android")), windows))]
|
||||||
impl Default for PlatformSecretStore {
|
impl Default for PlatformSecretStore {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "android")))]
|
#[cfg(any(all(unix, not(target_os = "android")), windows))]
|
||||||
impl SecretStore for PlatformSecretStore {
|
impl SecretStore for PlatformSecretStore {
|
||||||
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
|
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
|
||||||
Self::entry(secret_ref)?
|
Self::entry(secret_ref)?
|
||||||
@@ -156,7 +168,7 @@ impl SecretStore for PlatformSecretStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "android")))]
|
#[cfg(any(all(unix, not(target_os = "android")), windows))]
|
||||||
fn map_err(e: keyring::Error) -> SecretError {
|
fn map_err(e: keyring::Error) -> SecretError {
|
||||||
match e {
|
match e {
|
||||||
keyring::Error::NoEntry => SecretError::NotFound,
|
keyring::Error::NoEntry => SecretError::NotFound,
|
||||||
@@ -261,24 +273,24 @@ fn map_err(e: keyring_core::Error) -> SecretError {
|
|||||||
///
|
///
|
||||||
/// Failing loudly is deliberate: a silent no-op store would look like it
|
/// Failing loudly is deliberate: a silent no-op store would look like it
|
||||||
/// worked and then lose the credential.
|
/// worked and then lose the credential.
|
||||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
|
||||||
pub struct PlatformSecretStore;
|
pub struct PlatformSecretStore;
|
||||||
|
|
||||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
|
||||||
impl PlatformSecretStore {
|
impl PlatformSecretStore {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
|
||||||
impl Default for PlatformSecretStore {
|
impl Default for PlatformSecretStore {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))]
|
#[cfg(not(any(all(unix, not(target_os = "android")), windows, target_os = "android")))]
|
||||||
impl SecretStore for PlatformSecretStore {
|
impl SecretStore for PlatformSecretStore {
|
||||||
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
|
fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> {
|
||||||
Err(SecretError::Unavailable(
|
Err(SecretError::Unavailable(
|
||||||
|
|||||||
@@ -44,8 +44,7 @@
|
|||||||
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
|
//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call
|
||||||
//! sites: everything written here is readable by anyone holding the device.
|
//! sites: everything written here is readable by anyone holding the device.
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// Declared once by the platform entry point; a guess otherwise.
|
/// Declared once by the platform entry point; a guess otherwise.
|
||||||
@@ -72,66 +71,13 @@ pub fn state_dir() -> PathBuf {
|
|||||||
if let Some(dir) = STATE_DIR.get() {
|
if let Some(dir) = STATE_DIR.get() {
|
||||||
return dir.clone();
|
return dir.clone();
|
||||||
}
|
}
|
||||||
xdg_state_dir(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME"))
|
crate::dirs::base_dir(crate::dirs::Base::State)
|
||||||
}
|
|
||||||
|
|
||||||
/// The XDG resolution, as a function of its inputs rather than of the process
|
|
||||||
/// environment, so it can be tested without `set_var` racing every other test
|
|
||||||
/// in the binary.
|
|
||||||
///
|
|
||||||
/// Relative values are ignored rather than resolved against the working
|
|
||||||
/// directory: the base-directory specification says so explicitly, and the
|
|
||||||
/// alternative is a `darkroom/` directory appearing wherever the app was
|
|
||||||
/// launched from.
|
|
||||||
fn xdg_state_dir(xdg_state_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
|
|
||||||
xdg_state_home
|
|
||||||
.filter(|value| Path::new(value).is_absolute())
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.or_else(|| {
|
|
||||||
home.filter(|value| Path::new(value).is_absolute())
|
|
||||||
.map(|value| PathBuf::from(value).join(".local/state"))
|
|
||||||
})
|
|
||||||
// A container or a systemd unit with neither variable set. Writing a
|
|
||||||
// log into `/tmp` is a poor outcome; refusing to log at all, on the
|
|
||||||
// one kind of machine nobody is sitting in front of, is a worse one.
|
|
||||||
.unwrap_or_else(std::env::temp_dir)
|
|
||||||
.join("darkroom")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_log_goes_under_the_state_directory_the_user_named() {
|
|
||||||
// NFR-OPS-1 says "the XDG state directory", and honouring
|
|
||||||
// $XDG_STATE_HOME is the whole of what that means to a user who has
|
|
||||||
// moved theirs.
|
|
||||||
let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into()));
|
|
||||||
assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn without_the_variable_it_is_the_specifications_default() {
|
|
||||||
let dir = xdg_state_dir(None, Some("/home/someone".into()));
|
|
||||||
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_relative_value_is_ignored_rather_than_resolved() {
|
|
||||||
// The specification requires this, and the failure it prevents is a
|
|
||||||
// `darkroom/` directory appearing in whatever the working directory
|
|
||||||
// happened to be — including, on a desktop launcher, `/`.
|
|
||||||
let dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into()));
|
|
||||||
assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom"));
|
|
||||||
|
|
||||||
let dir = xdg_state_dir(Some("".into()), Some("".into()));
|
|
||||||
assert!(
|
|
||||||
dir.is_absolute(),
|
|
||||||
"an empty HOME must not produce a relative state directory"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_declared_directory_is_declared_once() {
|
fn a_declared_directory_is_declared_once() {
|
||||||
// The property the entry points rely on: two callers cannot split the
|
// The property the entry points rely on: two callers cannot split the
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ tokio.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
dr-plat.workspace = true
|
dr-plat.workspace = true
|
||||||
dr-sync.workspace = true
|
dr-sync.workspace = true
|
||||||
|
dr-xmp.workspace = true
|
||||||
dr-sync-folder.workspace = true
|
dr-sync-folder.workspace = true
|
||||||
dr-sync-nextcloud.workspace = true
|
dr-sync-nextcloud.workspace = true
|
||||||
dr-export.workspace = true
|
dr-export.workspace = true
|
||||||
|
|||||||
+557
-35
@@ -52,6 +52,13 @@ pub struct SyncReport {
|
|||||||
pub thumbnails_adopted: usize,
|
pub thumbnails_adopted: usize,
|
||||||
pub catalog_uploaded: bool,
|
pub catalog_uploaded: bool,
|
||||||
pub catalog_merged: bool,
|
pub catalog_merged: bool,
|
||||||
|
/// The copy on the server was damaged beyond reading, and ours replaced it.
|
||||||
|
///
|
||||||
|
/// Counted because it is the one outcome here that *destroys* something. A
|
||||||
|
/// sync that silently overwrote a peer's collections would be the bug this
|
||||||
|
/// whole path is written to avoid, so when it does happen the report has to
|
||||||
|
/// be able to say so rather than looking like an ordinary push.
|
||||||
|
pub catalog_replaced: bool,
|
||||||
pub collections_gained: usize,
|
pub collections_gained: usize,
|
||||||
/// Membership rows the merge brought in (FR-CAT-7).
|
/// Membership rows the merge brought in (FR-CAT-7).
|
||||||
///
|
///
|
||||||
@@ -88,6 +95,7 @@ impl SyncReport {
|
|||||||
|| self.shards_downloaded > 0
|
|| self.shards_downloaded > 0
|
||||||
|| self.catalog_uploaded
|
|| self.catalog_uploaded
|
||||||
|| self.catalog_merged
|
|| self.catalog_merged
|
||||||
|
|| self.catalog_replaced
|
||||||
|| self.face_shards_uploaded > 0
|
|| self.face_shards_uploaded > 0
|
||||||
|| self.face_shards_downloaded > 0
|
|| self.face_shards_downloaded > 0
|
||||||
|| self.place_adopted
|
|| self.place_adopted
|
||||||
@@ -583,11 +591,28 @@ fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exchange the catalog, for its collections.
|
/// Exchange the catalog, for its collections and its people.
|
||||||
///
|
///
|
||||||
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
|
/// Only collections, keywords and people merge — see [`dr_catalog::sync`]. The
|
||||||
/// describes local state (folder ETags, cache paths, job rows) and importing
|
/// rest of a catalog describes local state (folder ETags, cache paths, job
|
||||||
/// another device's version would be actively wrong.
|
/// rows) and importing another device's version would be actively wrong.
|
||||||
|
///
|
||||||
|
/// # Generations
|
||||||
|
///
|
||||||
|
/// TRACES: FR-NC-9 | NFR-R2
|
||||||
|
/// The server keeps the current copy and the [`GENERATIONS`] before it:
|
||||||
|
/// `catalog.sqlite`, then `catalog.1.sqlite` (the one it replaced), `.2`,
|
||||||
|
/// `.3`. A push uploads to a temporary name, rotates, and moves the upload into
|
||||||
|
/// place — so at no moment is there no current copy, and a copy that turns out
|
||||||
|
/// damaged has the one before it to fall back on. Rotation is server-side
|
||||||
|
/// renames; the only transfer is the upload itself.
|
||||||
|
///
|
||||||
|
/// This is what a damaged copy used to lack. With one copy and nothing behind
|
||||||
|
/// it, "the current file will not open" left two answers, both bad: refuse for
|
||||||
|
/// ever, or overwrite with ours and lose whatever another device had added
|
||||||
|
/// since. Now it has a third — merge from the newest readable generation, which
|
||||||
|
/// loses nothing — and the damaged file itself is kept as `.1` by the ordinary
|
||||||
|
/// rotation rather than by a separate upload.
|
||||||
async fn sync_catalog(
|
async fn sync_catalog(
|
||||||
backend: &dyn RemoteBackend,
|
backend: &dyn RemoteBackend,
|
||||||
base: &RemotePath,
|
base: &RemotePath,
|
||||||
@@ -595,7 +620,7 @@ async fn sync_catalog(
|
|||||||
scratch: &Path,
|
scratch: &Path,
|
||||||
report: &mut SyncReport,
|
report: &mut SyncReport,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let remote_name = "catalog.sqlite";
|
let remote_name = CATALOG_NAME;
|
||||||
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
|
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
|
||||||
|
|
||||||
// ---- take theirs first -----------------------------------------------
|
// ---- take theirs first -----------------------------------------------
|
||||||
@@ -627,30 +652,48 @@ async fn sync_catalog(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(bytes) = theirs {
|
if let Some(bytes) = theirs {
|
||||||
let downloaded = scratch.join("catalog-remote.sqlite");
|
let catalog = match dr_catalog::Catalog::open(catalog_path) {
|
||||||
if std::fs::write(&downloaded, &bytes).is_ok() {
|
Ok(c) => c,
|
||||||
match dr_catalog::Catalog::open(catalog_path) {
|
Err(e) => {
|
||||||
Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) {
|
log::warn!("not pushing the catalog: opening ours to merge: {e}");
|
||||||
Ok(merge) => {
|
return Ok(());
|
||||||
report.catalog_merged = true;
|
}
|
||||||
report.collections_gained = merge.inserted + merge.updated;
|
};
|
||||||
report.members_gained = merge.members_added;
|
match merge_downloaded(&catalog, scratch, &bytes, report) {
|
||||||
}
|
Ok(()) => {}
|
||||||
// Unreadable is not the same as absent: it may be a newer
|
// TRACES: FR-NC-9
|
||||||
// format, or a torn upload. Ours must not go over it.
|
// Damaged beyond reading, and the whole file arrived — so it is
|
||||||
Err(e) => {
|
// not a short download, and no device will read it either. Fall
|
||||||
log::warn!("not pushing the catalog: merging the server's copy: {e}");
|
// back to the generation before it; only with none readable is
|
||||||
let _ = std::fs::remove_file(&downloaded);
|
// ours the whole truth. See the header for why refusing here for
|
||||||
return Ok(());
|
// ever was the wrong answer.
|
||||||
}
|
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
|
||||||
},
|
if !arrived_whole(backend, base, remote_name, bytes.len(), &detail).await {
|
||||||
Err(e) => {
|
|
||||||
log::warn!("not pushing the catalog: opening ours to merge: {e}");
|
|
||||||
let _ = std::fs::remove_file(&downloaded);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
match merge_from_generations(backend, base, &catalog, scratch, report).await {
|
||||||
|
Some(n) => log::warn!(
|
||||||
|
"the catalog on the server is damaged ({detail}) and all {} bytes of \
|
||||||
|
it arrived, so no device can read it; merged from the generation \
|
||||||
|
before it ({}) instead, and replacing it",
|
||||||
|
bytes.len(),
|
||||||
|
generation_name(n)
|
||||||
|
),
|
||||||
|
None => log::warn!(
|
||||||
|
"the catalog on the server is damaged ({detail}) and all {} bytes of \
|
||||||
|
it arrived, so no device can read it, and no earlier generation is \
|
||||||
|
readable either; replacing it with this device's copy",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
report.catalog_replaced = true;
|
||||||
|
}
|
||||||
|
// Unreadable is not the same as absent: it may be a newer
|
||||||
|
// format. Ours must not go over it.
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("not pushing the catalog: merging the server's copy: {e}");
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&downloaded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,15 +709,177 @@ async fn sync_catalog(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
|
||||||
match backend.put(&target, bytes, None).await {
|
|
||||||
Ok(_) => report.catalog_uploaded = true,
|
|
||||||
Err(e) => log::warn!("uploading catalog: {e}"),
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_file(&snapshot);
|
let _ = std::fs::remove_file(&snapshot);
|
||||||
|
|
||||||
|
// To a temporary name first. The upload is the only step that can fail
|
||||||
|
// half-way, and a half-uploaded *current* copy is exactly the damaged file
|
||||||
|
// this whole scheme exists to survive. Under its own name a failure leaves
|
||||||
|
// the current copy untouched and costs one stray file, retried next pass.
|
||||||
|
let staging = RemotePath::new(format!("{}/{UPLOAD_NAME}", base.as_str()));
|
||||||
|
let sent = bytes.len() as u64;
|
||||||
|
if let Err(e) = backend.put(&staging, bytes, None).await {
|
||||||
|
log::warn!("uploading catalog: {e}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: NFR-R2
|
||||||
|
// Confirm the server holds what was sent before it becomes the copy every
|
||||||
|
// other device reads. A chunked upload is assembled server-side, and an
|
||||||
|
// assembly that went wrong is a file of plausible size that no device can
|
||||||
|
// open — the one failure the generations exist to survive, and cheaper
|
||||||
|
// to catch here, on the device that caused it, than on every other one
|
||||||
|
// after. One listing; the size is what the server can vouch for without
|
||||||
|
// reading the file back.
|
||||||
|
match remote_size(backend, base, UPLOAD_NAME).await {
|
||||||
|
Some(held) if held == sent => {}
|
||||||
|
Some(held) => {
|
||||||
|
log::warn!(
|
||||||
|
"not replacing the catalog: sent {sent} bytes but the server holds {held}; \
|
||||||
|
the upload is discarded and retried next pass"
|
||||||
|
);
|
||||||
|
let _ = backend.delete(&RemoteId::Path(staging), None).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::warn!(
|
||||||
|
"not replacing the catalog: the server would not confirm the upload's size; \
|
||||||
|
it is discarded and retried next pass"
|
||||||
|
);
|
||||||
|
let _ = backend.delete(&RemoteId::Path(staging), None).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate, then move the upload into place. Every step here is a rename on
|
||||||
|
// the server, and every destination is empty by the time it is written to
|
||||||
|
// — a `move_to` will not overwrite, by design — so a failure at any point
|
||||||
|
// leaves a gap in the generations and never a missing current copy.
|
||||||
|
if let Err(e) = rotate_generations(backend, base).await {
|
||||||
|
log::warn!("not replacing the catalog: rotating the earlier copies: {e}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match backend.move_to(&RemoteId::Path(staging), &target).await {
|
||||||
|
Ok(()) => report.catalog_uploaded = true,
|
||||||
|
Err(e) => log::warn!("moving the uploaded catalog into place: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The current copy's name on the server.
|
||||||
|
const CATALOG_NAME: &str = "catalog.sqlite";
|
||||||
|
|
||||||
|
/// Where a push lands before it is rotated into place.
|
||||||
|
const UPLOAD_NAME: &str = "catalog.upload.sqlite";
|
||||||
|
|
||||||
|
/// How many earlier copies the server keeps behind the current one.
|
||||||
|
///
|
||||||
|
/// Three, because what they are for is surviving one damaged push and the one
|
||||||
|
/// or two syncs it may take for a device to notice. More would cost nothing in
|
||||||
|
/// transfer — rotation is renames — but each is a 40 MB file on the account's
|
||||||
|
/// quota, and the local backups (NFR-R2) are the long-term store.
|
||||||
|
const GENERATIONS: usize = 3;
|
||||||
|
|
||||||
|
/// `catalog.N.sqlite` for `1 <= N <= GENERATIONS`; `.1` is the newest.
|
||||||
|
fn generation_name(n: usize) -> String {
|
||||||
|
format!("catalog.{n}.sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a downloaded catalog into ours, through a file in scratch.
|
||||||
|
///
|
||||||
|
/// Attaching needs a path, and the download is bytes. Written and removed here
|
||||||
|
/// so the callers — the current copy, and each generation tried after it —
|
||||||
|
/// cannot disagree about cleanup.
|
||||||
|
fn merge_downloaded(
|
||||||
|
catalog: &dr_catalog::Catalog,
|
||||||
|
scratch: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
report: &mut SyncReport,
|
||||||
|
) -> Result<(), dr_catalog::CatalogError> {
|
||||||
|
let downloaded = scratch.join("catalog-remote.sqlite");
|
||||||
|
std::fs::write(&downloaded, bytes).map_err(|e| dr_catalog::CatalogError::Io(e.to_string()))?;
|
||||||
|
let result = catalog.merge_remote_catalog(&downloaded);
|
||||||
|
let _ = std::fs::remove_file(&downloaded);
|
||||||
|
let merge = result?;
|
||||||
|
report.catalog_merged = true;
|
||||||
|
report.collections_gained += merge.inserted + merge.updated;
|
||||||
|
report.members_gained += merge.members_added;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-9
|
||||||
|
/// Merge from the newest generation that reads, when the current copy will
|
||||||
|
/// not. Returns which one, or `None` when none of them does.
|
||||||
|
///
|
||||||
|
/// Newest first, and the first readable one wins: a generation is a complete
|
||||||
|
/// snapshot, so an older one adds nothing a newer one lacks. A generation that
|
||||||
|
/// is absent, damaged, or from a newer schema is skipped the same way — none of
|
||||||
|
/// those is a reason to stop looking further back.
|
||||||
|
async fn merge_from_generations(
|
||||||
|
backend: &dyn RemoteBackend,
|
||||||
|
base: &RemotePath,
|
||||||
|
catalog: &dr_catalog::Catalog,
|
||||||
|
scratch: &Path,
|
||||||
|
report: &mut SyncReport,
|
||||||
|
) -> Option<usize> {
|
||||||
|
for n in 1..=GENERATIONS {
|
||||||
|
let path = RemotePath::new(format!("{}/{}", base.as_str(), generation_name(n)));
|
||||||
|
let bytes = match read_derived(backend, &path).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(RemoteError::NotFound(_)) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("skipping {}: {e}", generation_name(n));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match merge_downloaded(catalog, scratch, &bytes, report) {
|
||||||
|
Ok(()) => return Some(n),
|
||||||
|
Err(e) => log::debug!("skipping {}: {e}", generation_name(n)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make room for a new current copy: drop the oldest generation and shift the
|
||||||
|
/// rest back by one, ending with the current copy as `.1`.
|
||||||
|
///
|
||||||
|
/// Oldest first, so that each destination is empty when it is moved into —
|
||||||
|
/// `move_to` refuses to overwrite, and rightly. A name that is not there is
|
||||||
|
/// not an error at any step: a library that has synced twice has no `.3` yet.
|
||||||
|
async fn rotate_generations(backend: &dyn RemoteBackend, base: &RemotePath) -> Result<(), String> {
|
||||||
|
let at = |name: String| RemotePath::new(format!("{}/{name}", base.as_str()));
|
||||||
|
|
||||||
|
match backend
|
||||||
|
.delete(&RemoteId::Path(at(generation_name(GENERATIONS))), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) | Err(RemoteError::NotFound(_)) => {}
|
||||||
|
Err(e) => return Err(format!("dropping {}: {e}", generation_name(GENERATIONS))),
|
||||||
|
}
|
||||||
|
for n in (1..GENERATIONS).rev() {
|
||||||
|
match backend
|
||||||
|
.move_to(
|
||||||
|
&RemoteId::Path(at(generation_name(n))),
|
||||||
|
&at(generation_name(n + 1)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) | Err(RemoteError::NotFound(_)) => {}
|
||||||
|
Err(e) => return Err(format!("moving {} back: {e}", generation_name(n))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match backend
|
||||||
|
.move_to(
|
||||||
|
&RemoteId::Path(at(CATALOG_NAME.into())),
|
||||||
|
&at(generation_name(1)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) | Err(RemoteError::NotFound(_)) => Ok(()),
|
||||||
|
Err(e) => Err(format!("setting the current copy back: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-UI-8
|
/// TRACES: FR-UI-8
|
||||||
/// The place's name inside the derived folder.
|
/// The place's name inside the derived folder.
|
||||||
///
|
///
|
||||||
@@ -851,6 +1056,64 @@ async fn read_derived(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-9
|
||||||
|
/// Whether a download that will not open is the server's whole file.
|
||||||
|
///
|
||||||
|
/// A truncated download is also unreadable, and on a phone it is the far
|
||||||
|
/// likelier story — this library's logs are full of aborted bodies and DNS
|
||||||
|
/// failures. Treating that as damage would let one bad connection discard a
|
||||||
|
/// catalog the server was holding perfectly well. So the size the server
|
||||||
|
/// advertises is compared against what actually arrived, and anything short,
|
||||||
|
/// or any size the listing cannot confirm, is the transport failure it is:
|
||||||
|
/// the caller must leave the server's copy alone.
|
||||||
|
async fn arrived_whole(
|
||||||
|
backend: &dyn RemoteBackend,
|
||||||
|
base: &RemotePath,
|
||||||
|
name: &str,
|
||||||
|
received: usize,
|
||||||
|
detail: &str,
|
||||||
|
) -> bool {
|
||||||
|
let Some(advertised) = remote_size(backend, base, name).await else {
|
||||||
|
log::warn!(
|
||||||
|
"not pushing the catalog: the copy on the server will not open ({detail}), \
|
||||||
|
but its size could not be confirmed, so it may simply have arrived short"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if advertised != received as u64 {
|
||||||
|
log::warn!(
|
||||||
|
"not pushing the catalog: the copy on the server will not open ({detail}), \
|
||||||
|
but only {received} of {advertised} bytes arrived — that is a truncated \
|
||||||
|
download, not a damaged file, so the server's copy is left alone"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size the server says an entry in the derived folder has.
|
||||||
|
///
|
||||||
|
/// One listing of a folder that holds a handful of files, rather than a HEAD
|
||||||
|
/// the backend trait does not offer. `None` covers "the listing failed", "it is
|
||||||
|
/// not there", and "the size it reports means nothing" alike, and the caller
|
||||||
|
/// treats all of them as not knowing — which is the answer that declines to
|
||||||
|
/// overwrite.
|
||||||
|
///
|
||||||
|
/// A placeholder is excluded rather than trusted: `RemoteEntry::size` is
|
||||||
|
/// explicitly not meaningful when `materialised` is false — a suffix-mode stub
|
||||||
|
/// is one byte and carries no record of what it stands for — so comparing a
|
||||||
|
/// download against it would be comparing against nothing.
|
||||||
|
async fn remote_size(backend: &dyn RemoteBackend, base: &RemotePath, name: &str) -> Option<u64> {
|
||||||
|
backend
|
||||||
|
.list(base, None)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.kind == dr_sync::EntryKind::File && e.path.name() == name)
|
||||||
|
.filter(|e| e.materialised)
|
||||||
|
.map(|e| e.size)
|
||||||
|
}
|
||||||
|
|
||||||
fn shard_name(client: &str, id: u32) -> String {
|
fn shard_name(client: &str, id: u32) -> String {
|
||||||
format!("shard-{client}-{id:04}.sqlite")
|
format!("shard-{client}-{id:04}.sqlite")
|
||||||
}
|
}
|
||||||
@@ -979,6 +1242,19 @@ mod derived_guard_tests {
|
|||||||
/// record went up rather than only that one did.
|
/// record went up rather than only that one did.
|
||||||
last_put: Arc<std::sync::Mutex<Vec<u8>>>,
|
last_put: Arc<std::sync::Mutex<Vec<u8>>>,
|
||||||
caps: dr_sync::Capabilities,
|
caps: dr_sync::Capabilities,
|
||||||
|
/// The size `list` claims `catalog.sqlite` has, if it lists it at all.
|
||||||
|
/// This is what says whether a body that will not open is a damaged
|
||||||
|
/// file or merely a short download.
|
||||||
|
advertise: Option<u64>,
|
||||||
|
/// Bodies by file name, for tests that need the current copy and a
|
||||||
|
/// generation to differ. When empty every read serves `body`; when
|
||||||
|
/// not, a name absent here reads as `NotFound`.
|
||||||
|
bodies: std::collections::HashMap<String, Vec<u8>>,
|
||||||
|
/// Every `move_to`, as (from, to) names, in order.
|
||||||
|
moves: Arc<std::sync::Mutex<Vec<(String, String)>>>,
|
||||||
|
/// What `list` claims the staged upload's size is, when a test wants
|
||||||
|
/// the server to have assembled it wrongly. `None` reports the truth.
|
||||||
|
staged_size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fussy {
|
impl Fussy {
|
||||||
@@ -1000,6 +1276,10 @@ mod derived_guard_tests {
|
|||||||
puts: puts.clone(),
|
puts: puts.clone(),
|
||||||
last_put: last_put.clone(),
|
last_put: last_put.clone(),
|
||||||
caps: dr_sync::Capabilities::minimal(),
|
caps: dr_sync::Capabilities::minimal(),
|
||||||
|
advertise: None,
|
||||||
|
bodies: Default::default(),
|
||||||
|
moves: Default::default(),
|
||||||
|
staged_size: None,
|
||||||
},
|
},
|
||||||
puts,
|
puts,
|
||||||
last_put,
|
last_put,
|
||||||
@@ -1017,10 +1297,36 @@ mod derived_guard_tests {
|
|||||||
}
|
}
|
||||||
async fn list(
|
async fn list(
|
||||||
&self,
|
&self,
|
||||||
_dir: &RemotePath,
|
dir: &RemotePath,
|
||||||
_since: Option<&dr_sync::Validator>,
|
_since: Option<&dr_sync::Validator>,
|
||||||
) -> Result<Vec<dr_sync::RemoteEntry>, RemoteError> {
|
) -> Result<Vec<dr_sync::RemoteEntry>, RemoteError> {
|
||||||
Ok(Vec::new())
|
let entry = |name: &str, size: u64| {
|
||||||
|
let path = RemotePath::new(format!("{}/{name}", dir.as_str()));
|
||||||
|
dr_sync::RemoteEntry {
|
||||||
|
id: RemoteId::Path(path.clone()),
|
||||||
|
path,
|
||||||
|
kind: dr_sync::EntryKind::File,
|
||||||
|
validator: dr_sync::Validator::new("v"),
|
||||||
|
size,
|
||||||
|
modified: None,
|
||||||
|
has_preview: false,
|
||||||
|
materialised: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(size) = self.advertise {
|
||||||
|
out.push(entry(CATALOG_NAME, size));
|
||||||
|
}
|
||||||
|
// The staged upload lists at the size of what was last put, as a
|
||||||
|
// server that assembled it correctly would report — unless a test
|
||||||
|
// says the assembly went wrong.
|
||||||
|
if self.puts.load(Ordering::SeqCst) > 0 {
|
||||||
|
let held = self
|
||||||
|
.staged_size
|
||||||
|
.unwrap_or(self.last_put.lock().unwrap().len() as u64);
|
||||||
|
out.push(entry(UPLOAD_NAME, held));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
async fn dir_validator(
|
async fn dir_validator(
|
||||||
&self,
|
&self,
|
||||||
@@ -1036,7 +1342,7 @@ mod derived_guard_tests {
|
|||||||
}
|
}
|
||||||
async fn get(
|
async fn get(
|
||||||
&self,
|
&self,
|
||||||
_id: &RemoteId,
|
id: &RemoteId,
|
||||||
_r: Option<std::ops::Range<u64>>,
|
_r: Option<std::ops::Range<u64>>,
|
||||||
) -> Result<Vec<u8>, RemoteError> {
|
) -> Result<Vec<u8>, RemoteError> {
|
||||||
match &self.fail_with {
|
match &self.fail_with {
|
||||||
@@ -1045,7 +1351,17 @@ mod derived_guard_tests {
|
|||||||
Err(RemoteError::NotMaterialised(s.clone()))
|
Err(RemoteError::NotMaterialised(s.clone()))
|
||||||
}
|
}
|
||||||
Some(_) => Err(RemoteError::PermissionDenied),
|
Some(_) => Err(RemoteError::PermissionDenied),
|
||||||
None => Ok(self.body.clone()),
|
None if self.bodies.is_empty() => Ok(self.body.clone()),
|
||||||
|
None => {
|
||||||
|
let name = match id {
|
||||||
|
RemoteId::Path(p) => p.name().to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
self.bodies
|
||||||
|
.get(&name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(RemoteError::NotFound(name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn put(
|
async fn put(
|
||||||
@@ -1065,7 +1381,15 @@ mod derived_guard_tests {
|
|||||||
) -> Result<(), RemoteError> {
|
) -> Result<(), RemoteError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> {
|
async fn move_to(&self, f: &RemoteId, t: &RemotePath) -> Result<(), RemoteError> {
|
||||||
|
let from = match f {
|
||||||
|
RemoteId::Path(p) => p.name().to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
self.moves
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((from, t.name().to_string()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
|
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
|
||||||
@@ -1135,6 +1459,204 @@ mod derived_guard_tests {
|
|||||||
assert!(report.catalog_uploaded);
|
assert!(report.catalog_uploaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- a damaged copy on the server (FR-NC-9) --------------------------
|
||||||
|
//
|
||||||
|
// The one exception to everything above. A file SQLite calls malformed is
|
||||||
|
// not a read that failed; it is a file no device will ever read again, and
|
||||||
|
// leaving it alone pins it there for every client at once.
|
||||||
|
|
||||||
|
/// The bytes of a catalog holding one collection, as a peer would upload.
|
||||||
|
fn a_catalog_with(collection: &str, name: &str) -> Vec<u8> {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-generation-{name}"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("catalog.sqlite");
|
||||||
|
let cat = dr_catalog::Catalog::open(&path).unwrap();
|
||||||
|
cat.connection()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||||
|
VALUES (?1, ?2, 0, 0, 1, 1)",
|
||||||
|
rusqlite::params![format!("uuid-{collection}"), collection],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap = dir.join("snap.sqlite");
|
||||||
|
cat.snapshot_for_upload(&snap).unwrap();
|
||||||
|
let bytes = std::fs::read(&snap).unwrap();
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body that is definitely not a SQLite database as the current copy,
|
||||||
|
/// with a chosen advertised size and whatever generations the test wants.
|
||||||
|
async fn corrupt_remote(
|
||||||
|
body_len: usize,
|
||||||
|
advertised: Option<u64>,
|
||||||
|
generations: &[(usize, Vec<u8>)],
|
||||||
|
name: &str,
|
||||||
|
) -> (usize, SyncReport, Vec<(String, String)>) {
|
||||||
|
let (catalog_path, scratch) = fixture(name);
|
||||||
|
let (mut backend, puts, _) = Fussy::serving(None, Vec::new());
|
||||||
|
backend.advertise = advertised;
|
||||||
|
backend
|
||||||
|
.bodies
|
||||||
|
.insert(CATALOG_NAME.to_string(), vec![0xAB; body_len]);
|
||||||
|
for (n, bytes) in generations {
|
||||||
|
backend.bodies.insert(generation_name(*n), bytes.clone());
|
||||||
|
}
|
||||||
|
let moves = backend.moves.clone();
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
sync_catalog(
|
||||||
|
&backend,
|
||||||
|
&RemotePath::new(".darkroom-derived"),
|
||||||
|
&catalog_path,
|
||||||
|
&scratch,
|
||||||
|
&mut report,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let moves = moves.lock().unwrap().clone();
|
||||||
|
(puts.load(Ordering::SeqCst), report, moves)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_damaged_catalog_that_arrived_whole_is_replaced() {
|
||||||
|
// The deadlock this exists to break: every device downloads the same
|
||||||
|
// unreadable file, every device declines to overwrite it, and
|
||||||
|
// collections and people stop crossing between devices for ever.
|
||||||
|
let (puts, report, moves) = corrupt_remote(64, Some(64), &[], "corrupt-whole").await;
|
||||||
|
assert_eq!(puts, 1, "ours goes up, to the staging name");
|
||||||
|
assert!(report.catalog_replaced, "and the report says what happened");
|
||||||
|
assert!(report.catalog_uploaded);
|
||||||
|
assert!(
|
||||||
|
!report.catalog_merged,
|
||||||
|
"there was nothing readable to merge"
|
||||||
|
);
|
||||||
|
// The damaged file is kept by the rotation, not thrown away.
|
||||||
|
assert!(moves.contains(&(CATALOG_NAME.into(), generation_name(1))));
|
||||||
|
assert_eq!(
|
||||||
|
moves.last().unwrap(),
|
||||||
|
&(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
|
||||||
|
"and the upload is moved into place last"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_damaged_catalog_falls_back_to_the_generation_before_it() {
|
||||||
|
// What generations are for. The device that pushed the damaged copy
|
||||||
|
// may have been the only one holding some collection; the generation
|
||||||
|
// before it still has everything every device had agreed on.
|
||||||
|
let older = a_catalog_with("Iceland", "gen1");
|
||||||
|
let (puts, report, _) =
|
||||||
|
corrupt_remote(64, Some(64), &[(1, older)], "corrupt-with-gen").await;
|
||||||
|
assert!(report.catalog_merged, "merged from catalog.1.sqlite");
|
||||||
|
assert_eq!(report.collections_gained, 1, "and gained what it held");
|
||||||
|
assert!(report.catalog_replaced);
|
||||||
|
assert_eq!(puts, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_damaged_generation_is_skipped_for_the_one_behind_it() {
|
||||||
|
// Two bad pushes in a row must not be worse than one.
|
||||||
|
let older = a_catalog_with("Faroe", "gen2");
|
||||||
|
let (_, report, _) = corrupt_remote(
|
||||||
|
64,
|
||||||
|
Some(64),
|
||||||
|
&[(1, vec![0xCD; 64]), (2, older)],
|
||||||
|
"corrupt-two-deep",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(report.catalog_merged);
|
||||||
|
assert_eq!(report.collections_gained, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_short_download_is_a_truncated_transfer_not_a_damaged_file() {
|
||||||
|
// The failure that matters most to get right. A phone on a flaky link
|
||||||
|
// aborts bodies constantly, and a partial download will not open
|
||||||
|
// either — treating that as damage would let one bad connection
|
||||||
|
// destroy a catalog the server was holding perfectly well.
|
||||||
|
let (puts, report, moves) = corrupt_remote(64, Some(4096), &[], "corrupt-short").await;
|
||||||
|
assert_eq!(puts, 0, "nothing is written over a copy that arrived short");
|
||||||
|
assert!(moves.is_empty(), "and nothing is rotated");
|
||||||
|
assert!(!report.catalog_replaced);
|
||||||
|
assert!(!report.catalog_uploaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_size_the_server_will_not_confirm_leaves_the_copy_alone() {
|
||||||
|
// Not knowing is not the same as knowing it is whole. Without a size
|
||||||
|
// to compare against there is no way to tell damage from truncation,
|
||||||
|
// and the answer to "I cannot tell" has to stay "do not overwrite".
|
||||||
|
let (puts, report, _) = corrupt_remote(64, None, &[], "corrupt-unconfirmed").await;
|
||||||
|
assert_eq!(puts, 0);
|
||||||
|
assert!(!report.catalog_replaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_push_rotates_oldest_first_and_lands_last() {
|
||||||
|
// The order is the safety: every destination is empty when it is
|
||||||
|
// moved into, so a failure at any step leaves a gap and never a
|
||||||
|
// missing current copy.
|
||||||
|
let (puts, report, moves) =
|
||||||
|
run_with_moves(Some(RemoteError::NotFound("nope".into())), "rotation").await;
|
||||||
|
assert_eq!(puts, 1);
|
||||||
|
assert!(report.catalog_uploaded);
|
||||||
|
assert_eq!(
|
||||||
|
moves,
|
||||||
|
vec![
|
||||||
|
(generation_name(2), generation_name(3)),
|
||||||
|
(generation_name(1), generation_name(2)),
|
||||||
|
(CATALOG_NAME.to_string(), generation_name(1)),
|
||||||
|
(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_upload_the_server_holds_at_the_wrong_size_is_not_rotated_in() {
|
||||||
|
// The assembly went wrong on the server. Rotating that into place would
|
||||||
|
// hand every other device the damaged file the generations exist to
|
||||||
|
// survive; discarding it costs one retry.
|
||||||
|
let (catalog_path, scratch) = fixture("wrong-size");
|
||||||
|
let (mut backend, puts) = Fussy::reading(Some(RemoteError::NotFound("nope".into())));
|
||||||
|
backend.staged_size = Some(7);
|
||||||
|
let moves = backend.moves.clone();
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
sync_catalog(
|
||||||
|
&backend,
|
||||||
|
&RemotePath::new(".darkroom-derived"),
|
||||||
|
&catalog_path,
|
||||||
|
&scratch,
|
||||||
|
&mut report,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(puts.load(Ordering::SeqCst), 1, "it was uploaded");
|
||||||
|
assert!(!report.catalog_uploaded, "but not accepted");
|
||||||
|
assert!(moves.lock().unwrap().is_empty(), "and nothing was rotated");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_with_moves(
|
||||||
|
fail_with: Option<RemoteError>,
|
||||||
|
name: &str,
|
||||||
|
) -> (usize, SyncReport, Vec<(String, String)>) {
|
||||||
|
let (catalog_path, scratch) = fixture(name);
|
||||||
|
let (backend, puts) = Fussy::reading(fail_with);
|
||||||
|
let moves = backend.moves.clone();
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
sync_catalog(
|
||||||
|
&backend,
|
||||||
|
&RemotePath::new(".darkroom-derived"),
|
||||||
|
&catalog_path,
|
||||||
|
&scratch,
|
||||||
|
&mut report,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let moves = moves.lock().unwrap().clone();
|
||||||
|
(puts.load(Ordering::SeqCst), report, moves)
|
||||||
|
}
|
||||||
|
|
||||||
// --- the place (FR-UI-8) ---------------------------------------------
|
// --- the place (FR-UI-8) ---------------------------------------------
|
||||||
//
|
//
|
||||||
// The same "do not write over what you could not read" rule as above, for a
|
// The same "do not write over what you could not read" rule as above, for a
|
||||||
|
|||||||
+304
-2
@@ -778,6 +778,27 @@ pub struct DevelopSession {
|
|||||||
/// a history the *call sites* had to remember would be one press of undo
|
/// a history the *call sites* had to remember would be one press of undo
|
||||||
/// away from wrong every time a control is added.
|
/// away from wrong every time a control is added.
|
||||||
history: History,
|
history: History,
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The named snapshots of this edit, as the sidecar had them plus what
|
||||||
|
/// this sitting took, minus what it deleted.
|
||||||
|
///
|
||||||
|
/// Beside the history rather than inside it, because they answer a
|
||||||
|
/// different question. The history is what was done in this sitting and
|
||||||
|
/// is deliberately forgotten with it; a snapshot is a state the
|
||||||
|
/// photographer *named*, which is the act of saying it should outlive
|
||||||
|
/// the sitting. It is persisted as a version of the sidecar pointing at
|
||||||
|
/// this one (`Version::snapshot_of`), which is what makes it survive a
|
||||||
|
/// restart and reach the other device.
|
||||||
|
snapshots: Vec<dr_pipeline::Version>,
|
||||||
|
/// The ids of snapshots deleted this sitting, so the save can remove
|
||||||
|
/// them from the file without removing what another device added since
|
||||||
|
/// — see `Sidecar::replace_snapshots`.
|
||||||
|
removed_snapshots: Vec<String>,
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// The snapshot the canvas is showing instead of the edit, while a
|
||||||
|
/// comparison is held. Viewing state: nothing about the edit changes,
|
||||||
|
/// and it goes down with the session.
|
||||||
|
compared_snapshot: Option<String>,
|
||||||
demosaiced: Arc<DemosaicedImage>,
|
demosaiced: Arc<DemosaicedImage>,
|
||||||
adjust: AdjustPass,
|
adjust: AdjustPass,
|
||||||
/// TRACES: FR-DSP-7
|
/// TRACES: FR-DSP-7
|
||||||
@@ -1041,6 +1062,9 @@ impl DevelopSession {
|
|||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
graph,
|
graph,
|
||||||
history,
|
history,
|
||||||
|
snapshots: Vec::new(),
|
||||||
|
removed_snapshots: Vec::new(),
|
||||||
|
compared_snapshot: None,
|
||||||
demosaiced: Arc::new(demosaiced),
|
demosaiced: Arc::new(demosaiced),
|
||||||
adjust: AdjustPass::new(ctx),
|
adjust: AdjustPass::new(ctx),
|
||||||
histogram: HistogramPass::new(ctx)
|
histogram: HistogramPass::new(ctx)
|
||||||
@@ -2938,7 +2962,7 @@ impl DevelopSession {
|
|||||||
/// The join of the first is meaningless — there is nothing before it to
|
/// The join of the first is meaningless — there is nothing before it to
|
||||||
/// join to — and the panel shows it as the selection the layer *is*
|
/// join to — and the panel shows it as the selection the layer *is*
|
||||||
/// rather than as a row with a chip that does nothing.
|
/// rather than as a row with a chip that does nothing.
|
||||||
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> {
|
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
|
||||||
let Some(layer) = self.graph.masks().get(id) else {
|
let Some(layer) = self.graph.masks().get(id) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -2950,11 +2974,30 @@ impl DevelopSession {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|&j| j == p.join)
|
.position(|&j| j == p.join)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
(p.id.clone(), p.source.kind().to_string(), join)
|
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-19a
|
||||||
|
/// Leave one part out of the build, or put it back. An edit, and one
|
||||||
|
/// history step, for the same reason the layer's own switch is: the part
|
||||||
|
/// really is out until it is switched back.
|
||||||
|
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
|
||||||
|
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(part) = layer.part_mut(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if part.hidden == hidden {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
part.hidden = hidden;
|
||||||
|
self.history
|
||||||
|
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-19a
|
/// TRACES: FR-DEV-19a
|
||||||
/// Join a fresh painted part to a layer, returning its index.
|
/// Join a fresh painted part to a layer, returning its index.
|
||||||
///
|
///
|
||||||
@@ -5427,6 +5470,162 @@ impl DevelopSession {
|
|||||||
self.history.reset(&self.graph);
|
self.history.reset(&self.graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- named snapshots ---------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// Hand the session the snapshots its sidecar holds. Called once, on
|
||||||
|
/// open, beside [`Self::apply_version`].
|
||||||
|
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
|
||||||
|
self.snapshots = snapshots;
|
||||||
|
self.removed_snapshots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshots as they stand, oldest first.
|
||||||
|
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
|
||||||
|
&self.snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids deleted this sitting, for the save.
|
||||||
|
pub fn removed_snapshots(&self) -> &[String] {
|
||||||
|
&self.removed_snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// Name the state the photograph is in, and keep it. Returns the id.
|
||||||
|
///
|
||||||
|
/// Not a history step: taking a snapshot changes nothing about the edit,
|
||||||
|
/// and an undo that removed one would be undoing a decision to remember
|
||||||
|
/// rather than a change to the photograph. Deleting one is the same.
|
||||||
|
///
|
||||||
|
/// The id is stamped with the second and a per-process random word
|
||||||
|
/// rather than counted, because two devices can each take a snapshot of
|
||||||
|
/// the same photograph and both have to survive the merge — which keys
|
||||||
|
/// on this id, and would fold two `snap-3`s into one.
|
||||||
|
pub fn take_snapshot(&mut self, name: &str) -> String {
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let salt = std::collections::hash_map::RandomState::new()
|
||||||
|
.build_hasher()
|
||||||
|
.finish();
|
||||||
|
let id = format!("snap-{now}-{:08x}", salt as u32);
|
||||||
|
|
||||||
|
let name = name.trim();
|
||||||
|
let name = if name.is_empty() {
|
||||||
|
format!("Snapshot {}", self.snapshots.len() + 1)
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
};
|
||||||
|
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
|
||||||
|
// The stack with the model's coverage folded in, for the reason the
|
||||||
|
// save uses it: a subject layer stored by identity alone renders as
|
||||||
|
// nothing until a model is run, and a snapshot restored on the other
|
||||||
|
// device, or in a batch export, never gets one.
|
||||||
|
version.masks = self.masks_for_storage();
|
||||||
|
version.modified = now;
|
||||||
|
self.snapshots.push(version);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// Put the photograph back the way a snapshot has it. One history step,
|
||||||
|
/// so it is undoable as a whole, exactly as a paste is.
|
||||||
|
pub fn restore_snapshot(&mut self, id: &str) -> bool {
|
||||||
|
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rebake = version.apply(&mut self.graph);
|
||||||
|
self.pay_film_debt(&rebake);
|
||||||
|
self.history
|
||||||
|
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
|
||||||
|
v.name = name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// Forget a snapshot. Remembered as a deletion so the save takes it out
|
||||||
|
/// of the file rather than merely not putting it back.
|
||||||
|
pub fn delete_snapshot(&mut self, id: &str) {
|
||||||
|
let before = self.snapshots.len();
|
||||||
|
self.snapshots.retain(|v| v.uuid != id);
|
||||||
|
if self.snapshots.len() != before {
|
||||||
|
self.removed_snapshots.push(id.to_string());
|
||||||
|
}
|
||||||
|
if self.compared_snapshot.as_deref() == Some(id) {
|
||||||
|
self.compared_snapshot = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// Hold a comparison against a snapshot, or let it go. Returns whether
|
||||||
|
/// anything changed, so a repeat costs no render.
|
||||||
|
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
|
||||||
|
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
|
||||||
|
if self.compared_snapshot.as_deref() == id {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.compared_snapshot = id.map(str::to_string);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshot being held against the edit, if one is.
|
||||||
|
pub fn compared_snapshot(&self) -> Option<&str> {
|
||||||
|
self.compared_snapshot.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// Render the photograph as a snapshot has it, without becoming it.
|
||||||
|
///
|
||||||
|
/// The same suspension [`Self::render_original`] uses — borrow the graph
|
||||||
|
/// for one render and hand it back — because it is the same question
|
||||||
|
/// about a different reference point: "the version I liked twenty
|
||||||
|
/// minutes ago" instead of the file. Nothing is recorded and nothing is
|
||||||
|
/// marked modified. A held comparison against a snapshot that has since
|
||||||
|
/// been deleted falls back to the edit itself, which is what is on
|
||||||
|
/// screen anyway.
|
||||||
|
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||||||
|
let Some(version) = self
|
||||||
|
.compared_snapshot
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return self.render(width, height);
|
||||||
|
};
|
||||||
|
let saved = self.graph.state();
|
||||||
|
let debt = version.apply(&mut self.graph);
|
||||||
|
self.pay_film_debt(&debt);
|
||||||
|
let rendered = self.render(width, height);
|
||||||
|
let debt = self.graph.set_state(&saved);
|
||||||
|
self.pay_film_debt(&debt);
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The snapshot list as the panel draws it, oldest first.
|
||||||
|
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
|
||||||
|
self.snapshots
|
||||||
|
.iter()
|
||||||
|
.map(|v| crate::SnapshotRow {
|
||||||
|
id: v.uuid.as_str().into(),
|
||||||
|
name: v.name.as_str().into(),
|
||||||
|
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3f | FR-DEV-5
|
/// TRACES: FR-DEV-3f | FR-DEV-5
|
||||||
/// Pay what a restored edit owes the picture.
|
/// Pay what a restored edit owes the picture.
|
||||||
///
|
///
|
||||||
@@ -6519,6 +6718,109 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// A snapshot is a state the photographer named: taking one changes
|
||||||
|
/// nothing, going back to it is one step, and undo takes the whole of
|
||||||
|
/// that step back.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
|
||||||
|
let rows = session.rows();
|
||||||
|
let row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| {
|
||||||
|
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||||
|
!session.is_neutral()
|
||||||
|
})
|
||||||
|
.expect("some control in the panel moves the picture")
|
||||||
|
.clone();
|
||||||
|
let liked = session.copy_settings();
|
||||||
|
let steps_before = session.history_rows().len();
|
||||||
|
|
||||||
|
let id = session.take_snapshot("Liked this");
|
||||||
|
assert_eq!(session.snapshots().len(), 1);
|
||||||
|
assert_eq!(session.snapshots()[0].name, "Liked this");
|
||||||
|
assert_eq!(
|
||||||
|
session.history_rows().len(),
|
||||||
|
steps_before,
|
||||||
|
"naming a state is not a change to the photograph"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move on, then go back.
|
||||||
|
session.set_param(row.op_index, row.param_index, row.minimum);
|
||||||
|
let moved_on = session.copy_settings();
|
||||||
|
assert_ne!(moved_on, liked, "the premise: the edit has moved");
|
||||||
|
let steps_moved = session.history_rows().len();
|
||||||
|
|
||||||
|
assert!(session.restore_snapshot(&id));
|
||||||
|
assert_eq!(session.copy_settings(), liked, "back to the named state");
|
||||||
|
assert_eq!(
|
||||||
|
session.history_rows().len(),
|
||||||
|
steps_moved + 1,
|
||||||
|
"restoring is one step"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(session.undo());
|
||||||
|
assert_eq!(
|
||||||
|
session.copy_settings(),
|
||||||
|
moved_on,
|
||||||
|
"and undo takes the whole restore back"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A name nobody typed is numbered rather than blank.
|
||||||
|
session.take_snapshot(" ");
|
||||||
|
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
|
||||||
|
|
||||||
|
session.delete_snapshot(&id);
|
||||||
|
assert_eq!(session.snapshots().len(), 1);
|
||||||
|
assert_eq!(session.removed_snapshots(), [id.as_str()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||||||
|
/// Holding a snapshot against the edit is the same bargain as holding
|
||||||
|
/// the original: the picture changes, and nothing else does.
|
||||||
|
#[test]
|
||||||
|
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
|
||||||
|
let rows = session.rows();
|
||||||
|
let row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|row| {
|
||||||
|
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||||
|
!session.is_neutral()
|
||||||
|
})
|
||||||
|
.expect("some control in the panel moves the picture")
|
||||||
|
.clone();
|
||||||
|
let id = session.take_snapshot("Bright");
|
||||||
|
session.set_param(row.op_index, row.param_index, row.minimum);
|
||||||
|
let edit = session.copy_settings();
|
||||||
|
let steps = session.history_rows().len();
|
||||||
|
|
||||||
|
assert!(session.compare_snapshot(Some(&id)), "the hold began");
|
||||||
|
assert!(
|
||||||
|
!session.compare_snapshot(Some(&id)),
|
||||||
|
"a repeat of the same hold is not a change"
|
||||||
|
);
|
||||||
|
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
|
||||||
|
session
|
||||||
|
.render_compared(64, 64)
|
||||||
|
.expect("render the snapshot");
|
||||||
|
|
||||||
|
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
|
||||||
|
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
|
||||||
|
|
||||||
|
assert!(session.compare_snapshot(None), "and letting go is one");
|
||||||
|
assert!(session.compared_snapshot().is_none());
|
||||||
|
assert!(
|
||||||
|
!session.compare_snapshot(Some("nothing-by-this-name")),
|
||||||
|
"a snapshot that does not exist cannot be held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
/// Reopening an edited photograph renders its subject mask, with no model.
|
/// Reopening an edited photograph renders its subject mask, with no model.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
section: "Develop",
|
section: "Develop",
|
||||||
touch: "Double-tap its track",
|
touch: "Double-tap its track",
|
||||||
pointer: "Double-click its track, or right-click it",
|
pointer: "Double-click its track, or right-click it",
|
||||||
|
keys: "R, for the control last moved",
|
||||||
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "See the photograph as a snapshot had it",
|
||||||
|
section: "Develop",
|
||||||
|
touch: "Press and hold the eye beside the snapshot",
|
||||||
|
pointer: "Press and hold the eye beside the snapshot",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Keep the photograph as it is now, under a name",
|
||||||
|
section: "Develop",
|
||||||
|
touch: "Type a name in the History panel and press Snapshot",
|
||||||
|
pointer: "Type a name in the History panel and press Snapshot",
|
||||||
keys: "",
|
keys: "",
|
||||||
},
|
},
|
||||||
Gesture {
|
Gesture {
|
||||||
@@ -118,7 +132,7 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
section: "Develop",
|
section: "Develop",
|
||||||
touch: "Tap the ring at the head of its row",
|
touch: "Tap the ring at the head of its row",
|
||||||
pointer: "Click the ring at the head of its row",
|
pointer: "Click the ring at the head of its row",
|
||||||
keys: "",
|
keys: "H, for the selected layer history step, unlike holding \"Before\" — the layer really is off until it is switched back on.",
|
||||||
},
|
},
|
||||||
Gesture {
|
Gesture {
|
||||||
title: "Show or hide one mask on the photograph",
|
title: "Show or hide one mask on the photograph",
|
||||||
@@ -127,6 +141,13 @@ pub const GESTURES: &[Gesture] = &[
|
|||||||
pointer: "Click the eye on its row",
|
pointer: "Click the eye on its row",
|
||||||
keys: "",
|
keys: "",
|
||||||
},
|
},
|
||||||
|
Gesture {
|
||||||
|
title: "Leave one part out of a mask, and put it back",
|
||||||
|
section: "Develop",
|
||||||
|
touch: "Tap the ring on the part's row",
|
||||||
|
pointer: "Click the ring on the part's row",
|
||||||
|
keys: "",
|
||||||
|
},
|
||||||
Gesture {
|
Gesture {
|
||||||
title: "Pick a collection up to rearrange the tree",
|
title: "Pick a collection up to rearrange the tree",
|
||||||
section: "Collections sidebar",
|
section: "Collections sidebar",
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub mod step {
|
|||||||
|
|
||||||
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
||||||
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The photograph put back to a named snapshot, as one step.
|
||||||
|
pub const SNAPSHOT_RESTORED: LocalizedKey = LocalizedKey("history.snapshot_restored");
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// A neutral was picked off the photograph, setting the white balance.
|
/// A neutral was picked off the photograph, setting the white balance.
|
||||||
@@ -71,6 +74,7 @@ pub mod step {
|
|||||||
/// A selection joined to a mask, taken out of it, or joined the other way.
|
/// A selection joined to a mask, taken out of it, or joined the other way.
|
||||||
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
|
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
|
||||||
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
|
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
|
||||||
|
pub const MASK_PART_TOGGLED: LocalizedKey = LocalizedKey("history.mask_part_toggled");
|
||||||
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
|
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
|
||||||
|
|
||||||
/// TRACES: FR-DEV-8
|
/// TRACES: FR-DEV-8
|
||||||
@@ -96,6 +100,7 @@ pub mod step {
|
|||||||
dr_pipeline::history::OPENED,
|
dr_pipeline::history::OPENED,
|
||||||
dr_pipeline::history::UNNAMED,
|
dr_pipeline::history::UNNAMED,
|
||||||
PASTE,
|
PASTE,
|
||||||
|
SNAPSHOT_RESTORED,
|
||||||
FILM,
|
FILM,
|
||||||
SAMPLED_NEUTRAL,
|
SAMPLED_NEUTRAL,
|
||||||
RESET_ALL,
|
RESET_ALL,
|
||||||
@@ -121,6 +126,7 @@ pub mod step {
|
|||||||
MASK_ERASED,
|
MASK_ERASED,
|
||||||
MASK_PART_ADDED,
|
MASK_PART_ADDED,
|
||||||
MASK_PART_REMOVED,
|
MASK_PART_REMOVED,
|
||||||
|
MASK_PART_TOGGLED,
|
||||||
MASK_JOINED,
|
MASK_JOINED,
|
||||||
SPOT,
|
SPOT,
|
||||||
SPOT_PLACED,
|
SPOT_PLACED,
|
||||||
@@ -287,6 +293,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
"history.opened" => "Opened",
|
"history.opened" => "Opened",
|
||||||
"history.edit" => "Edit",
|
"history.edit" => "Edit",
|
||||||
"history.paste" => "Paste Settings",
|
"history.paste" => "Paste Settings",
|
||||||
|
"history.snapshot_restored" => "Restore A Snapshot",
|
||||||
"history.film" => "Film Stock",
|
"history.film" => "Film Stock",
|
||||||
"history.sampled_neutral" => "Sample Neutral",
|
"history.sampled_neutral" => "Sample Neutral",
|
||||||
|
|
||||||
@@ -315,6 +322,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
"history.mask_erased" => "Erase Mask",
|
"history.mask_erased" => "Erase Mask",
|
||||||
"history.mask_part_added" => "Add To Mask",
|
"history.mask_part_added" => "Add To Mask",
|
||||||
"history.mask_part_removed" => "Remove From Mask",
|
"history.mask_part_removed" => "Remove From Mask",
|
||||||
|
"history.mask_part_toggled" => "Show Or Hide Part Of A Mask",
|
||||||
"history.mask_joined" => "Change How A Mask Joins",
|
"history.mask_joined" => "Change How A Mask Joins",
|
||||||
"history.spot" => "Adjust Repair",
|
"history.spot" => "Adjust Repair",
|
||||||
"history.spot_placed" => "Add Repair",
|
"history.spot_placed" => "Add Repair",
|
||||||
|
|||||||
@@ -816,9 +816,26 @@ fn open_in_browser(url: &str) -> std::io::Result<()> {
|
|||||||
{
|
{
|
||||||
android_open_url(url).map_err(|e| std::io::Error::other(e))
|
android_open_url(url).map_err(|e| std::io::Error::other(e))
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-PLAT-WIN-2
|
||||||
|
// `ShellExecute` by way of the shell's URL handler, which is what a
|
||||||
|
// double-click on a link does, and needs no crate: `rundll32
|
||||||
|
// url.dll,FileProtocolHandler` has opened the default browser since
|
||||||
|
// Windows 98 and is still what the platform documents for the purpose.
|
||||||
|
// Not `cmd /C start`, whose quoting of `&` in a query string is a
|
||||||
|
// well-known trap.
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
std::process::Command::new("rundll32")
|
||||||
|
.args(["url.dll,FileProtocolHandler", url])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
all(unix, not(target_os = "android"), not(target_os = "macos")),
|
all(unix, not(target_os = "android"), not(target_os = "macos")),
|
||||||
target_os = "android"
|
target_os = "android",
|
||||||
|
windows
|
||||||
)))]
|
)))]
|
||||||
{
|
{
|
||||||
let _ = url;
|
let _ = url;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ mod settings_ui;
|
|||||||
mod sidecar_cache;
|
mod sidecar_cache;
|
||||||
mod spots_ui;
|
mod spots_ui;
|
||||||
mod trash;
|
mod trash;
|
||||||
|
mod xmp_sync;
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -414,6 +415,12 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// the first click on the *next* photograph sample it, which is a click
|
// the first click on the *next* photograph sample it, which is a click
|
||||||
// nobody meant to spend.
|
// nobody meant to spend.
|
||||||
window.global::<Adjustments>().set_sampling_op(-1);
|
window.global::<Adjustments>().set_sampling_op(-1);
|
||||||
|
// TRACES: FR-DEV-16
|
||||||
|
// And the control last moved, which R resets: the indices are into this
|
||||||
|
// photograph's rows, and a reset that reached back into the previous edit
|
||||||
|
// would land on whichever control happens to share them.
|
||||||
|
window.global::<Adjustments>().set_touched_op(-1);
|
||||||
|
window.global::<Adjustments>().set_touched_param(-1);
|
||||||
// TRACES: FR-DSP-7
|
// TRACES: FR-DSP-7
|
||||||
// Emptied rather than left standing: the previous photograph's histogram
|
// Emptied rather than left standing: the previous photograph's histogram
|
||||||
// beside the next one's filename is a confident, precise lie, and the gap
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
@@ -1186,6 +1193,79 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
None => window.set_backend("NO GPU".into()),
|
None => window.set_backend("NO GPU".into()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: NFR-OPS-1
|
||||||
|
// The diagnostics bundle, wired as the two presses the requirement
|
||||||
|
// describes. Preparing gathers the log and the crash records into memory
|
||||||
|
// and shows what would be written; saving writes it; discarding drops it
|
||||||
|
// and writes nothing. The gathered bundle is held between the presses so
|
||||||
|
// that what is saved is exactly what was shown — a bundle gathered again
|
||||||
|
// at save time could differ from its own preview by whatever was logged
|
||||||
|
// while the user was reading.
|
||||||
|
{
|
||||||
|
use dr_plat::diagnostics::bundle::{Bundle, Facts};
|
||||||
|
let facts = Facts {
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
schema_version: dr_catalog::schema::SCHEMA_VERSION,
|
||||||
|
graphics: match &gpu {
|
||||||
|
Some(ctx) => format!(
|
||||||
|
"{} ({:?}), {}",
|
||||||
|
ctx.adapter_name(),
|
||||||
|
ctx.backend(),
|
||||||
|
ctx.driver()
|
||||||
|
),
|
||||||
|
None => "no GPU".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let pending: Rc<RefCell<Option<Bundle>>> = Rc::new(RefCell::new(None));
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let pending = pending.clone();
|
||||||
|
window.on_diagnostics_prepare(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let bundle = Bundle::gather(&facts);
|
||||||
|
w.set_diagnostics_preview(
|
||||||
|
bundle
|
||||||
|
.preview(&dr_plat::diagnostics::bundle::default_dir())
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
w.set_diagnostics_result("".into());
|
||||||
|
*pending.borrow_mut() = Some(bundle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let pending = pending.clone();
|
||||||
|
window.on_diagnostics_save(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(bundle) = pending.borrow_mut().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dir = dr_plat::diagnostics::bundle::default_dir();
|
||||||
|
let result = match bundle.write_to(&dir) {
|
||||||
|
Ok(path) => {
|
||||||
|
log::info!("diagnostics bundle written: {}", path.display());
|
||||||
|
format!("Saved as {}", path.display())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("diagnostics bundle not written: {e}");
|
||||||
|
format!("Could not save the bundle to {}: {e}", dir.display())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
w.set_diagnostics_preview("".into());
|
||||||
|
w.set_diagnostics_result(result.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.on_diagnostics_discard(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
*pending.borrow_mut() = None;
|
||||||
|
w.set_diagnostics_preview("".into());
|
||||||
|
w.set_diagnostics_result("".into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Every background job reports here, and this draws the bar across the top
|
// Every background job reports here, and this draws the bar across the top
|
||||||
// of the shell and fills the settings page's list. Built before the
|
// of the shell and fills the settings page's list. Built before the
|
||||||
// controllers because they take a handle to it: a job that starts during
|
// controllers because they take a handle to it: a job that starts during
|
||||||
@@ -1504,6 +1584,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let stored = settings.snapshot();
|
let stored = settings.snapshot();
|
||||||
library.set_cache_budget(stored.cache.original_budget_bytes);
|
library.set_cache_budget(stored.cache.original_budget_bytes);
|
||||||
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
|
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
|
||||||
|
library.set_write_xmp_sidecars(stored.library.write_xmp_sidecars);
|
||||||
library.set_timeline_bars(stored.library.timeline_bars);
|
library.set_timeline_bars(stored.library.timeline_bars);
|
||||||
library.set_face_model_id(stored.faces.detector.model_id());
|
library.set_face_model_id(stored.faces.detector.model_id());
|
||||||
}
|
}
|
||||||
@@ -1635,6 +1716,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// cache would sit over budget indefinitely.
|
// cache would sit over budget indefinitely.
|
||||||
lib.set_cache_budget(s.cache.original_budget_bytes);
|
lib.set_cache_budget(s.cache.original_budget_bytes);
|
||||||
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
|
lib.set_keep_opened_originals(s.cache.keep_opened_originals);
|
||||||
|
lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars);
|
||||||
if let Some(w) = weak.upgrade() {
|
if let Some(w) = weak.upgrade() {
|
||||||
refresh_export_label(&w, &ctl);
|
refresh_export_label(&w, &ctl);
|
||||||
}
|
}
|
||||||
@@ -1759,6 +1841,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// the list is rebuilt when it would read differently, not when the picture
|
// the list is rebuilt when it would read differently, not when the picture
|
||||||
// is redrawn, and those are very different rates.
|
// is redrawn, and those are very different rates.
|
||||||
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// And the snapshot list, compared by value rather than by a revision: it
|
||||||
|
// is a handful of rows, and a held comparison changes one of them
|
||||||
|
// without a step being taken.
|
||||||
|
let drawn_snapshots: Rc<RefCell<Option<Vec<SnapshotRow>>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
// TRACES: FR-CULL-3
|
// TRACES: FR-CULL-3
|
||||||
// How the photographer wants focus peaking drawn, or `None` for off.
|
// How the photographer wants focus peaking drawn, or `None` for off.
|
||||||
@@ -1795,6 +1882,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let viewport = viewport.clone();
|
let viewport = viewport.clone();
|
||||||
let drawn_history = drawn_history.clone();
|
let drawn_history = drawn_history.clone();
|
||||||
|
let drawn_snapshots = drawn_snapshots.clone();
|
||||||
let display = display.clone();
|
let display = display.clone();
|
||||||
let chosen_peaking = chosen_peaking.clone();
|
let chosen_peaking = chosen_peaking.clone();
|
||||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||||
@@ -1837,6 +1925,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
|
steps.set_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
|
||||||
steps.set_undo_label(s.undo_label().into());
|
steps.set_undo_label(s.undo_label().into());
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// The snapshots, on the same path. Rebuilt only when the list
|
||||||
|
// would read differently, for the reason the steps are: a held
|
||||||
|
// comparison redraws, and it is the one thing here that changes
|
||||||
|
// a row without changing the history.
|
||||||
|
let snapshots = s.snapshot_rows();
|
||||||
|
if drawn_snapshots.borrow().as_ref() != Some(&snapshots) {
|
||||||
|
*drawn_snapshots.borrow_mut() = Some(snapshots.clone());
|
||||||
|
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-3
|
// TRACES: FR-DEV-3
|
||||||
// Which part of the region overlay the view is showing. Here
|
// Which part of the region overlay the view is showing. Here
|
||||||
@@ -1900,6 +1998,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||||||
} else if window.get_showing_original() {
|
} else if window.get_showing_original() {
|
||||||
s.render_original(w, h)
|
s.render_original(w, h)
|
||||||
|
} else if s.compared_snapshot().is_some() {
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// The same hold, against a snapshot rather than the file.
|
||||||
|
s.render_compared(w, h)
|
||||||
} else {
|
} else {
|
||||||
s.render(w, h)
|
s.render(w, h)
|
||||||
};
|
};
|
||||||
@@ -2964,6 +3066,80 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// Named snapshots. Taking and deleting one change the list and nothing
|
||||||
|
// else — no rows, no history — so they redraw only to push the list,
|
||||||
|
// through the same path everything else pushes it on. Restoring one is
|
||||||
|
// the paste's shape: the whole edit moves, so the rows resync and the
|
||||||
|
// picture is redrawn.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.global::<Steps>().on_snapshot_taken(move |name| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.take_snapshot(&name);
|
||||||
|
}
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
window.global::<Steps>().on_snapshot_restored(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let restored = session
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|s| s.restore_snapshot(&id));
|
||||||
|
if restored {
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
// The mask panel too: a snapshot carries its layers, and
|
||||||
|
// the redraw syncs the repairs and the overlay but not the
|
||||||
|
// rows the layers are listed in.
|
||||||
|
masks_ui::sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.global::<Steps>().on_snapshot_removed(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.delete_snapshot(&id);
|
||||||
|
}
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// Holding a snapshot against the edit, and letting it go — the
|
||||||
|
// Before button's shape, and its rule: no rows are synced and no
|
||||||
|
// history is touched. A full frame rather than a draft, for the
|
||||||
|
// reason the original is: a soft comparison shows a difference the
|
||||||
|
// edit does not have.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let render_now = render_now.clone();
|
||||||
|
window
|
||||||
|
.global::<Steps>()
|
||||||
|
.on_snapshot_compared(move |id, down| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let changed = session
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|s| s.compare_snapshot(down.then_some(id.as_str())));
|
||||||
|
if changed {
|
||||||
|
render_now(&w, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- zoom, pan and crop ---------------------------------------------
|
// ---- zoom, pan and crop ---------------------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
+268
-33
@@ -544,6 +544,11 @@ pub enum Amendment {
|
|||||||
/// `Some(None)` is a real edit: "develop this normally again". Without
|
/// `Some(None)` is a real edit: "develop this normally again". Without
|
||||||
/// the distinction, clearing a film could never be saved.
|
/// the distinction, clearing a film could never be saved.
|
||||||
film: Option<Option<dr_pipeline::sidecar::FilmRef>>,
|
film: Option<Option<dr_pipeline::sidecar::FilmRef>>,
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The named snapshots, when this is an image's own edit being
|
||||||
|
/// written back: the ones the session holds, and the ids it deleted.
|
||||||
|
/// A paste carries none — a snapshot is a state of one photograph.
|
||||||
|
snapshots: Option<(Vec<dr_pipeline::Version>, Vec<String>)>,
|
||||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
/// The local adjustments, when this is an image's own edit being
|
/// The local adjustments, when this is an image's own edit being
|
||||||
/// written back rather than a paste onto someone else's.
|
/// written back rather than a paste onto someone else's.
|
||||||
@@ -795,6 +800,7 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
|
|||||||
scope,
|
scope,
|
||||||
masks,
|
masks,
|
||||||
film,
|
film,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
preset.amend(&mut version.params, *scope);
|
preset.amend(&mut version.params, *scope);
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
@@ -823,6 +829,17 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
|
|||||||
version.modified = now_secs();
|
version.modified = now_secs();
|
||||||
|
|
||||||
sidecar.put(version);
|
sidecar.put(version);
|
||||||
|
// TRACES: FR-DEV-5 | FR-NC-9
|
||||||
|
// After the version, and against the uuid the fuse settled on: a
|
||||||
|
// snapshot points at its edit by uuid, and the edit may have just been
|
||||||
|
// renamed onto the canonical one.
|
||||||
|
if let Amendment::Settings {
|
||||||
|
snapshots: Some((kept, removed)),
|
||||||
|
..
|
||||||
|
} = &w.amendment
|
||||||
|
{
|
||||||
|
sidecar.replace_snapshots(&w.version_uuid, kept.clone(), removed);
|
||||||
|
}
|
||||||
sidecar
|
sidecar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,18 +1155,16 @@ pub fn place_path(account: &Account) -> PathBuf {
|
|||||||
/// reached the server, is the worst failure this application can have, and
|
/// reached the server, is the worst failure this application can have, and
|
||||||
/// it would be silent.
|
/// it would be silent.
|
||||||
///
|
///
|
||||||
/// `AccountStore::data_dir()` is the persistent per-app directory the
|
/// `dr_sync::account::declared_data_dir` is the persistent per-app directory
|
||||||
/// Android entry point establishes before anything opens a store. On a
|
/// the Android entry point establishes before anything opens a store. A
|
||||||
/// desktop it is the XDG config directory, and the two lines below keep the
|
/// desktop declares nothing and takes the platform's data directory from
|
||||||
/// established XDG *data* location there rather than moving anyone's
|
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
|
||||||
/// catalog.
|
/// the established location on Linux rather than moving anyone's catalog.
|
||||||
fn data_root() -> PathBuf {
|
fn data_root() -> PathBuf {
|
||||||
let base = std::env::var_os("XDG_DATA_HOME")
|
match dr_sync::account::declared_data_dir() {
|
||||||
.map(PathBuf::from)
|
Some(declared) => declared.join("darkroom"),
|
||||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
|
None => dr_plat::base_dir(dr_plat::Base::Data),
|
||||||
.unwrap_or_else(dr_sync::AccountStore::data_dir);
|
}
|
||||||
|
|
||||||
base.join("darkroom")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-10 | NFR-R1
|
/// TRACES: FR-NC-10 | NFR-R1
|
||||||
@@ -1481,6 +1496,34 @@ async fn pull_sidecars(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-13
|
||||||
|
// A standard XMP beside the photograph — Lightroom's, darktable's,
|
||||||
|
// anybody's — takes the other branch: reconciled field by field with
|
||||||
|
// the catalog winning, and a disagreement recorded for the reload
|
||||||
|
// the requirement asks to be offered. See `xmp_sync`.
|
||||||
|
if crate::xmp_sync::is_xmp(path) {
|
||||||
|
match crate::xmp_sync::take_in(conn, root_id, path, &text, now_secs()) {
|
||||||
|
Ok(taken) => {
|
||||||
|
applied += taken.changed;
|
||||||
|
if !taken.conflicts.is_empty() {
|
||||||
|
log::info!(
|
||||||
|
"xmp sidecar {path} disagrees with the catalog on {:?}; \
|
||||||
|
a reload is offered in Settings",
|
||||||
|
taken.conflicts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Recorded only once it reached a photograph: a sidecar
|
||||||
|
// that arrived before its image is read again next time.
|
||||||
|
if taken.described > 0 {
|
||||||
|
record_sidecar_read(conn, root_id, path, &entry.validator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("xmp sidecar at {path} is unreadable ({e})"),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let mut sidecar = match dr_pipeline::Sidecar::parse(&text) {
|
let mut sidecar = match dr_pipeline::Sidecar::parse(&text) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
// Unreadable is not empty. Recording the ETag would mean never
|
// Unreadable is not empty. Recording the ETag would mean never
|
||||||
@@ -1517,6 +1560,191 @@ async fn pull_sidecars(
|
|||||||
Ok(applied)
|
Ok(applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// One image's ratings, label and keywords, on their way to the `.xmp`
|
||||||
|
/// beside it.
|
||||||
|
///
|
||||||
|
/// Distinct from [`SidecarWrite`], which amends DarkRoom's own document
|
||||||
|
/// through the cache and the outbox. This is best-effort in the other
|
||||||
|
/// direction: the catalog and the `.drsc` are authoritative, the `.xmp` is a
|
||||||
|
/// courtesy to whatever else reads the folder, and a write that cannot
|
||||||
|
/// happen now is written again — from the catalog, whole — the next time
|
||||||
|
/// anything about the photograph is judged. So nothing is queued.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct XmpWrite {
|
||||||
|
pub image_path: String,
|
||||||
|
pub record: dr_xmp::Xmp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// Write each record into the XMP sidecar beside its image.
|
||||||
|
///
|
||||||
|
/// An existing sidecar of either spelling is rewritten in place, which is the
|
||||||
|
/// whole point of `dr_xmp::rewrite`: only the properties DarkRoom owns move,
|
||||||
|
/// and another application's settings, comments and namespaces come through
|
||||||
|
/// byte for byte. A photograph with neither gets a new file under Lightroom's
|
||||||
|
/// name. Reported once at the end, as the judgement writes are.
|
||||||
|
pub fn spawn_xmp_writes(conn: Connection, writes: Vec<XmpWrite>) -> Receiver<XmpMessage> {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut written = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
let mut last_error = None;
|
||||||
|
let rt = match crate::net_runtime::build() {
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(XmpMessage::Finished {
|
||||||
|
written: 0,
|
||||||
|
failed: writes.len(),
|
||||||
|
last_error: Some(e.to_string()),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rt.block_on(async {
|
||||||
|
let backend = match crate::remote::connect(&conn) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
failed = writes.len();
|
||||||
|
last_error = Some(e.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for w in &writes {
|
||||||
|
match write_one_xmp(&*backend, w).await {
|
||||||
|
Ok(()) => written += 1,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("xmp sidecar for {}: {e}", w.image_path);
|
||||||
|
last_error = Some(e);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = tx.send(XmpMessage::Finished {
|
||||||
|
written,
|
||||||
|
failed,
|
||||||
|
last_error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a batch of XMP writes.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum XmpMessage {
|
||||||
|
Finished {
|
||||||
|
written: usize,
|
||||||
|
failed: usize,
|
||||||
|
last_error: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write one image's XMP sidecar on the server.
|
||||||
|
async fn write_one_xmp(backend: &dyn RemoteBackend, w: &XmpWrite) -> Result<(), String> {
|
||||||
|
let [darktable, lightroom] = crate::xmp_sync::candidate_paths(&w.image_path);
|
||||||
|
// Whichever exists is the one rewritten; neither existing means the
|
||||||
|
// Lightroom spelling is created. An existing file this build cannot
|
||||||
|
// parse is left alone rather than replaced — it is somebody else's
|
||||||
|
// document, and a refusal is recoverable where an overwrite is not.
|
||||||
|
let mut target = lightroom.clone();
|
||||||
|
let mut existing: Option<String> = None;
|
||||||
|
for path in [&darktable, &lightroom] {
|
||||||
|
if let Ok(bytes) = backend
|
||||||
|
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
target = path.clone();
|
||||||
|
existing = Some(String::from_utf8_lossy(&bytes).into_owned());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = match existing {
|
||||||
|
Some(text) => {
|
||||||
|
// The file's caption, copyright and hierarchy come through: the
|
||||||
|
// catalog has nowhere to keep them, and a rewrite that said
|
||||||
|
// nothing about them would remove them.
|
||||||
|
let theirs = dr_xmp::Xmp::parse(&text)
|
||||||
|
.map_err(|e| format!("{target} is not a sidecar this build can read: {e}"))?;
|
||||||
|
let mut record = w.record.clone();
|
||||||
|
crate::xmp_sync::carry_through(&mut record, &theirs);
|
||||||
|
record
|
||||||
|
.rewrite(&text)
|
||||||
|
.map_err(|e| format!("{target} is not a sidecar this build can rewrite: {e}"))?
|
||||||
|
}
|
||||||
|
None => w.record.to_text(),
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.put(&RemotePath::new(target), text.into_bytes(), None)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// The offered reload: re-read the sidecars a person chose to trust, with
|
||||||
|
/// the sidecar winning. One fetch per path, the catalog opened on this
|
||||||
|
/// thread as the scan opens it.
|
||||||
|
pub fn spawn_xmp_reload(
|
||||||
|
conn: Connection,
|
||||||
|
root: String,
|
||||||
|
catalog_path: PathBuf,
|
||||||
|
paths: Vec<String>,
|
||||||
|
) -> Receiver<XmpMessage> {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut written = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
let mut last_error = None;
|
||||||
|
let outcome: Result<(), String> = (|| {
|
||||||
|
let catalog = Catalog::open(&catalog_path).map_err(|e| e.to_string())?;
|
||||||
|
let root_id: i64 = catalog
|
||||||
|
.connection()
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||||||
|
[&root],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rt = crate::net_runtime::build().map_err(|e| e.to_string())?;
|
||||||
|
rt.block_on(async {
|
||||||
|
let backend = crate::remote::connect(&conn).map_err(|e| e.to_string())?;
|
||||||
|
for path in &paths {
|
||||||
|
let fetched = backend
|
||||||
|
.get(&RemoteId::Path(RemotePath::new(path.clone())), None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|bytes| {
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
crate::xmp_sync::reload(catalog.connection(), root_id, path, &text)
|
||||||
|
});
|
||||||
|
match fetched {
|
||||||
|
Ok(taken) => written += taken.changed,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("reloading {path}: {e}");
|
||||||
|
last_error = Some(e);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})();
|
||||||
|
if let Err(e) = outcome {
|
||||||
|
failed = paths.len();
|
||||||
|
last_error = Some(e);
|
||||||
|
}
|
||||||
|
let _ = tx.send(XmpMessage::Finished {
|
||||||
|
written,
|
||||||
|
failed,
|
||||||
|
last_error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
/// What this device has already read, so a pull fetches only what changed.
|
/// What this device has already read, so a pull fetches only what changed.
|
||||||
fn load_sidecar_etags(
|
fn load_sidecar_etags(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
@@ -4423,14 +4651,30 @@ pub fn face_models(
|
|||||||
account: &Account,
|
account: &Account,
|
||||||
detector: dr_types::FaceDetector,
|
detector: dr_types::FaceDetector,
|
||||||
) -> Option<(PathBuf, PathBuf)> {
|
) -> Option<(PathBuf, PathBuf)> {
|
||||||
let pair = |dir: PathBuf| {
|
let pair = |dir: &PathBuf| {
|
||||||
let detector = dir.join(detector.file_name());
|
let detector = dir.join(detector.file_name());
|
||||||
let embedder = dir.join("arcface_mbf_b1.onnx");
|
let embedder = dir.join("arcface_mbf_b1.onnx");
|
||||||
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
|
(detector.is_file() && embedder.is_file()).then_some((detector, embedder))
|
||||||
};
|
};
|
||||||
pair(face_models_dir(account))
|
let mut searched = vec![face_models_dir(account), shared_face_models_dir()];
|
||||||
.or_else(|| pair(shared_face_models_dir()))
|
searched.extend(system_face_models_dirs());
|
||||||
.or_else(|| system_face_models_dirs().into_iter().find_map(pair))
|
let found = searched.iter().find_map(pair);
|
||||||
|
if found.is_none() {
|
||||||
|
// The settings page can only say "not installed". This is the line
|
||||||
|
// that says where it looked, which is the whole of what a user with
|
||||||
|
// the files in the wrong place needs — and the first thing to read
|
||||||
|
// when a freshly installed package reports no model.
|
||||||
|
log::warn!(
|
||||||
|
"face models: no directory holds both {} and arcface_mbf_b1.onnx; searched {}",
|
||||||
|
detector.file_name(),
|
||||||
|
searched
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.display().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scene model, its vocabulary and its category descriptor, if all three
|
/// The scene model, its vocabulary and its category descriptor, if all three
|
||||||
@@ -4466,25 +4710,16 @@ pub fn scene_model(account: &Account) -> Option<(PathBuf, PathBuf, PathBuf)> {
|
|||||||
|
|
||||||
/// Where a *package* may have installed the models.
|
/// Where a *package* may have installed the models.
|
||||||
///
|
///
|
||||||
/// `$XDG_DATA_DIRS` rather than a hard-coded `/usr/share`, because that is the
|
/// `dr_plat::system_data_dirs` has the rule per platform: `$XDG_DATA_DIRS` on
|
||||||
/// variable a distribution, a prefix install, or a Nix-style store sets to say
|
/// Linux, the executable's own directory on Windows, nothing on Android —
|
||||||
/// where its data went, and the default it falls back to is exactly the pair of
|
/// there the APK's copy is unpacked into the shared user directory instead,
|
||||||
/// paths that would otherwise have been hard-coded.
|
/// because an asset inside a package is not a path anything can read from
|
||||||
///
|
/// (ARCH §6.9). Last in the search order on every platform, so a pair the
|
||||||
/// Empty on Android, which has no such directories: there the APK's copy is
|
/// user placed by hand outranks the installed one.
|
||||||
/// unpacked into the shared user directory instead, because an asset inside a
|
|
||||||
/// package is not a path anything can read from (ARCH §6.9).
|
|
||||||
fn system_face_models_dirs() -> Vec<PathBuf> {
|
fn system_face_models_dirs() -> Vec<PathBuf> {
|
||||||
if cfg!(target_os = "android") {
|
dr_plat::system_data_dirs()
|
||||||
return Vec::new();
|
.into_iter()
|
||||||
}
|
.map(|d| d.join("models"))
|
||||||
let dirs = std::env::var("XDG_DATA_DIRS")
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
|
||||||
dirs.split(':')
|
|
||||||
.filter(|d| !d.is_empty())
|
|
||||||
.map(|d| PathBuf::from(d).join("darkroom").join("models"))
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+262
-4
@@ -298,6 +298,9 @@ pub struct LibraryController {
|
|||||||
/// Drains the sidecar writer. Held so a second judgement replaces the
|
/// Drains the sidecar writer. Held so a second judgement replaces the
|
||||||
/// timer rather than leaving two draining the same finished channel.
|
/// timer rather than leaving two draining the same finished channel.
|
||||||
sidecar_timer: RefCell<Option<slint::Timer>>,
|
sidecar_timer: RefCell<Option<slint::Timer>>,
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// The same, for a batch of XMP writes or a reload.
|
||||||
|
xmp_timer: RefCell<Option<slint::Timer>>,
|
||||||
/// Which window load the model belongs to, bumped by [`load_window`].
|
/// Which window load the model belongs to, bumped by [`load_window`].
|
||||||
///
|
///
|
||||||
/// A thumbnail worker addresses cells by *row index into the window that
|
/// A thumbnail worker addresses cells by *row index into the window that
|
||||||
@@ -381,6 +384,10 @@ pub struct LibraryController {
|
|||||||
/// small budget still keeps a working set. A metered or small-disk device
|
/// small budget still keeps a working set. A metered or small-disk device
|
||||||
/// wants the first.
|
/// wants the first.
|
||||||
keep_opened: std::cell::Cell<bool>,
|
keep_opened: std::cell::Cell<bool>,
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// Whether judgements and keywords also go to the `.xmp` beside the
|
||||||
|
/// original. Mirrors `LibrarySettings::write_xmp_sidecars`.
|
||||||
|
write_xmp: std::cell::Cell<bool>,
|
||||||
/// TRACES: FR-CAT-6
|
/// TRACES: FR-CAT-6
|
||||||
/// How many bars the capture-time axis is cut into, from the settings
|
/// How many bars the capture-time axis is cut into, from the settings
|
||||||
/// page.
|
/// page.
|
||||||
@@ -450,6 +457,7 @@ impl LibraryController {
|
|||||||
current_bucket: RefCell::new(None),
|
current_bucket: RefCell::new(None),
|
||||||
filter: RefCell::new(library::RatingFilter::default()),
|
filter: RefCell::new(library::RatingFilter::default()),
|
||||||
sidecar_timer: RefCell::new(None),
|
sidecar_timer: RefCell::new(None),
|
||||||
|
xmp_timer: RefCell::new(None),
|
||||||
generation: std::cell::Cell::new(0),
|
generation: std::cell::Cell::new(0),
|
||||||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||||||
root_lost: RefCell::new(None),
|
root_lost: RefCell::new(None),
|
||||||
@@ -466,6 +474,9 @@ impl LibraryController {
|
|||||||
keep_opened: std::cell::Cell::new(
|
keep_opened: std::cell::Cell::new(
|
||||||
dr_types::CacheSettings::default().keep_opened_originals,
|
dr_types::CacheSettings::default().keep_opened_originals,
|
||||||
),
|
),
|
||||||
|
write_xmp: std::cell::Cell::new(
|
||||||
|
dr_types::LibrarySettings::default().write_xmp_sidecars,
|
||||||
|
),
|
||||||
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
|
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
|
||||||
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
|
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
|
||||||
})
|
})
|
||||||
@@ -505,6 +516,11 @@ impl LibraryController {
|
|||||||
self.keep_opened.set(keep);
|
self.keep_opened.set(keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
pub fn set_write_xmp_sidecars(&self, on: bool) {
|
||||||
|
self.write_xmp.set(on);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-6a
|
/// TRACES: FR-NC-6a
|
||||||
/// Set the ceiling on passively cached originals, and apply it now.
|
/// Set the ceiling on passively cached originals, and apply it now.
|
||||||
///
|
///
|
||||||
@@ -1266,6 +1282,11 @@ fn drain_scan(
|
|||||||
log::info!("library folder is readable again");
|
log::info!("library folder is readable again");
|
||||||
}
|
}
|
||||||
refresh_offline(&w, ctl);
|
refresh_offline(&w, ctl);
|
||||||
|
// TRACES: FR-CAT-13
|
||||||
|
// The pull may have found an `.xmp` that disagrees
|
||||||
|
// with the catalog; the settings page offers the
|
||||||
|
// reload, and this is what tells it how many.
|
||||||
|
refresh_xmp_conflicts(&w, ctl);
|
||||||
|
|
||||||
// An incremental rescan lists almost nothing, so
|
// An incremental rescan lists almost nothing, so
|
||||||
// reporting the listed count would read as "0 images"
|
// reporting the listed count would read as "0 images"
|
||||||
@@ -3086,6 +3107,7 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
|
|||||||
window.set_library_error(slint::SharedString::new());
|
window.set_library_error(slint::SharedString::new());
|
||||||
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||||||
refresh_keywords(window, ctl, &images);
|
refresh_keywords(window, ctl, &images);
|
||||||
|
start_xmp_writes(window, ctl, &images);
|
||||||
|
|
||||||
// A filtered grid may no longer hold what was just keyworded — taking
|
// A filtered grid may no longer hold what was just keyworded — taking
|
||||||
// "puffin" off an image while showing only puffins means it belongs
|
// "puffin" off an image while showing only puffins means it belongs
|
||||||
@@ -3194,6 +3216,7 @@ fn apply_judgement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_sidecar_writes(window, ctl, writes);
|
start_sidecar_writes(window, ctl, writes);
|
||||||
|
start_xmp_writes(window, ctl, images);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the status line says about a judgement that just landed.
|
/// What the status line says about a judgement that just landed.
|
||||||
@@ -3321,6 +3344,9 @@ fn collect_settings_writes(
|
|||||||
// not a parameter. Pasting one would be pasting a choice the
|
// not a parameter. Pasting one would be pasting a choice the
|
||||||
// clipboard never captured.
|
// clipboard never captured.
|
||||||
film: None,
|
film: None,
|
||||||
|
// Nor snapshots: they are states of the photograph they were
|
||||||
|
// taken on, and mean nothing on another.
|
||||||
|
snapshots: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -3382,6 +3408,186 @@ fn collect_sidecar_writes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// Write these images' ratings, labels and keywords to the `.xmp` beside
|
||||||
|
/// each, where the user has switched that on.
|
||||||
|
///
|
||||||
|
/// The record is read from the catalog *now*, whole, rather than carried
|
||||||
|
/// from the gesture: a rating and a keyword typed a second apart are two
|
||||||
|
/// writes of the same file, and the second must not carry a copy of the
|
||||||
|
/// first taken before it landed.
|
||||||
|
pub(crate) fn start_xmp_writes(
|
||||||
|
window: &AppWindow,
|
||||||
|
ctl: &Rc<LibraryController>,
|
||||||
|
images: &[dr_types::ImageId],
|
||||||
|
) {
|
||||||
|
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let writes: Vec<library::XmpWrite> = {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let c = catalog.connection();
|
||||||
|
images
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&image| {
|
||||||
|
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
|
||||||
|
let image_path: String = c
|
||||||
|
.query_row(
|
||||||
|
"SELECT source_ref FROM images WHERE id = ?1",
|
||||||
|
[image.0 as i64],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(library::XmpWrite {
|
||||||
|
image_path,
|
||||||
|
record: crate::xmp_sync::record_of(c, image, version),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if writes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = writes.len();
|
||||||
|
let rx = library::spawn_xmp_writes(conn, writes);
|
||||||
|
let job = ctl.activity.begin(
|
||||||
|
crate::activity::Kind::Upload,
|
||||||
|
format!("Writing {count} XMP sidecar(s)"),
|
||||||
|
);
|
||||||
|
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// The offered reload: take the sidecars' values for every photograph the
|
||||||
|
/// last pull found disagreeing with the catalog.
|
||||||
|
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
|
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let paths: Vec<String> = {
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let Some(catalog) = borrow.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::xmp_sync::conflicts(catalog, root_id)
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.path)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if paths.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = paths.len();
|
||||||
|
let rx = library::spawn_xmp_reload(
|
||||||
|
conn.clone(),
|
||||||
|
conn.account.root.clone(),
|
||||||
|
library::catalog_path(&conn.account),
|
||||||
|
paths,
|
||||||
|
);
|
||||||
|
let job = ctl.activity.begin(
|
||||||
|
crate::activity::Kind::Download,
|
||||||
|
format!("Reloading {count} XMP sidecar(s)"),
|
||||||
|
);
|
||||||
|
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a batch of XMP work to report, then say what it did.
|
||||||
|
fn drain_xmp(
|
||||||
|
weak: slint::Weak<AppWindow>,
|
||||||
|
ctl: Rc<LibraryController>,
|
||||||
|
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
|
||||||
|
job: crate::activity::Activity,
|
||||||
|
reload: bool,
|
||||||
|
) {
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
let job = RefCell::new(Some(job));
|
||||||
|
let ctl_cb = ctl.clone();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::Repeated,
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || {
|
||||||
|
let ctl = &ctl_cb;
|
||||||
|
let message = match rx.try_recv() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||||
|
stop(&ctl.xmp_timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stop(&ctl.xmp_timer);
|
||||||
|
let library::XmpMessage::Finished {
|
||||||
|
written,
|
||||||
|
failed,
|
||||||
|
last_error,
|
||||||
|
} = message;
|
||||||
|
let status = match (reload, failed, last_error) {
|
||||||
|
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
|
||||||
|
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
|
||||||
|
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
|
||||||
|
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
|
||||||
|
};
|
||||||
|
if let Some(job) = job.borrow_mut().take() {
|
||||||
|
if failed > 0 {
|
||||||
|
job.fail(status.clone());
|
||||||
|
} else {
|
||||||
|
job.finish(status.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_library_status(status.into());
|
||||||
|
if reload {
|
||||||
|
// The grid draws what the reload changed, and the
|
||||||
|
// settings page stops offering what is settled.
|
||||||
|
let visible = ctl.visible_ids();
|
||||||
|
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
|
||||||
|
sync_ratings(&w, catalog, &visible);
|
||||||
|
refresh_rating_counts(&w, catalog);
|
||||||
|
}
|
||||||
|
refresh_xmp_conflicts(&w, ctl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*ctl.xmp_timer.borrow_mut() = Some(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// How many sidecars the last pull found disagreeing with the catalog, for
|
||||||
|
/// the settings page to offer the reload against.
|
||||||
|
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
|
let count = (|| {
|
||||||
|
let (conn, _) = ctl.session.borrow().clone()?;
|
||||||
|
let borrow = ctl.catalog.borrow();
|
||||||
|
let catalog = borrow.as_ref()?;
|
||||||
|
let root_id = root_id_of(catalog, &conn.account.root)?;
|
||||||
|
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
|
||||||
|
})()
|
||||||
|
.unwrap_or(0);
|
||||||
|
window.set_settings_xmp_conflicts(count as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
|
||||||
|
catalog
|
||||||
|
.connection()
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||||||
|
[root],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn start_sidecar_writes(
|
pub(crate) fn start_sidecar_writes(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
ctl: &Rc<LibraryController>,
|
ctl: &Rc<LibraryController>,
|
||||||
@@ -3949,6 +4155,34 @@ fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
|
|||||||
refresh_timeline(window, catalog, ctl);
|
refresh_timeline(window, catalog, ctl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: NFR-R2
|
||||||
|
/// Take the day's catalog backup if one is due, off the UI thread.
|
||||||
|
///
|
||||||
|
/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a
|
||||||
|
/// directory listing, not the catalog — so the ordinary case of "backed up
|
||||||
|
/// this morning already" costs no thread and no connection.
|
||||||
|
fn spawn_scheduled_backup(ctl: &Rc<LibraryController>) {
|
||||||
|
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let catalog_path = library::catalog_path(&conn.account);
|
||||||
|
if !dr_catalog::recovery::backup_due(&catalog_path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let catalog = match dr_catalog::Catalog::open(&catalog_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("scheduled backup: opening the catalog: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) {
|
||||||
|
log::warn!("scheduled backup: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Push shards and the catalog to the server, and take what it has.
|
/// Push shards and the catalog to the server, and take what it has.
|
||||||
///
|
///
|
||||||
/// Fired after the sweep completes, when there is a finished index worth
|
/// Fired after the sweep completes, when there is a finished index worth
|
||||||
@@ -4080,10 +4314,14 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
report.shards_uploaded,
|
report.shards_uploaded,
|
||||||
report.shards_downloaded,
|
report.shards_downloaded,
|
||||||
report.thumbnails_adopted,
|
report.thumbnails_adopted,
|
||||||
if report.catalog_uploaded {
|
// Replacing a damaged copy is said apart from an
|
||||||
"pushed"
|
// ordinary push: it is the one push that discarded
|
||||||
} else {
|
// something, and a log line that called it "pushed"
|
||||||
"not pushed"
|
// would hide the only moment worth going back to.
|
||||||
|
match (report.catalog_uploaded, report.catalog_replaced) {
|
||||||
|
(true, true) => "pushed over a damaged copy",
|
||||||
|
(true, false) => "pushed",
|
||||||
|
_ => "not pushed",
|
||||||
},
|
},
|
||||||
if report.collections_gained > 0 {
|
if report.collections_gained > 0 {
|
||||||
format!(", {} collection(s) gained", report.collections_gained)
|
format!(", {} collection(s) gained", report.collections_gained)
|
||||||
@@ -4235,6 +4473,14 @@ fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
refresh_timeline(&w, catalog, &ctl_cb);
|
refresh_timeline(&w, catalog, &ctl_cb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TRACES: NFR-R2
|
||||||
|
// The scheduled backup, at the moment the catalog is
|
||||||
|
// quietest and a day's edits have just been folded in.
|
||||||
|
// On its own thread, with its own connection: a copy
|
||||||
|
// of a 130 MB file is a second or two the Slint loop
|
||||||
|
// must not spend, and it runs beside the sync below,
|
||||||
|
// which is only a reader of the same file.
|
||||||
|
spawn_scheduled_backup(&ctl_cb);
|
||||||
// Now that indexing is complete, hand the result to the
|
// Now that indexing is complete, hand the result to the
|
||||||
// server so a second device inherits it rather than
|
// server so a second device inherits it rather than
|
||||||
// repeating hours of range fetches.
|
// repeating hours of range fetches.
|
||||||
@@ -5500,6 +5746,18 @@ pub fn wire<F>(
|
|||||||
// controllers would hold each other alive for the life of the process.
|
// controllers would hold each other alive for the life of the process.
|
||||||
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
|
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-13
|
||||||
|
// The reload the settings page offers when a sidecar disagrees.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_settings_xmp_reload(move || {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
start_xmp_reload(&w, &ctl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-UI-3 | FR-UI-4
|
// TRACES: FR-UI-3 | FR-UI-4
|
||||||
// Whether the rating strip waits to be hovered or stands open.
|
// Whether the rating strip waits to be hovered or stands open.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -275,12 +275,13 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
|
|||||||
.mask_parts(id)
|
.mask_parts(id)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (part_id, label, join))| PartRow {
|
.map(|(i, (part_id, label, join, hidden))| PartRow {
|
||||||
id: part_id.into(),
|
id: part_id.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
join: join as i32,
|
join: join as i32,
|
||||||
selected: i == s.active_part(),
|
selected: i == s.active_part(),
|
||||||
base: i == 0,
|
base: i == 0,
|
||||||
|
hidden,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
@@ -665,6 +666,34 @@ pub(crate) fn wire(
|
|||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-DEV-16
|
||||||
|
// The keyboard's copy of the ring. A mixed selection goes to shown:
|
||||||
|
// a layer nobody can see is the one being asked about, and turning
|
||||||
|
// the rest off to match it would hide work in the name of hiding it.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window.global::<Masking>().on_selected_toggled(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
{
|
||||||
|
let mut slot = session.borrow_mut();
|
||||||
|
let Some(s) = slot.as_mut() else { return };
|
||||||
|
let ids: Vec<String> = s.active_masks().to_vec();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let all_shown = ids
|
||||||
|
.iter()
|
||||||
|
.all(|id| s.masks().get(id).is_some_and(|l| l.enabled));
|
||||||
|
for id in &ids {
|
||||||
|
s.set_mask_enabled(id, !all_shown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
@@ -1056,6 +1085,25 @@ pub(crate) fn wire(
|
|||||||
redraw(&w);
|
redraw(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-DEV-19a
|
||||||
|
// A part left out of the build. The picture changes and the row's
|
||||||
|
// eye does, so both are redrawn; the adjust rows are not, because
|
||||||
|
// the layer's chain is not what moved.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
window
|
||||||
|
.global::<Masking>()
|
||||||
|
.on_part_hidden_toggled(move |id, index, hidden| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.set_mask_part_hidden(&id, index.max(0) as usize, hidden);
|
||||||
|
}
|
||||||
|
sync(&w, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
|
|||||||
+96
-4
@@ -315,6 +315,7 @@ pub fn save_local(
|
|||||||
preset: &Preset,
|
preset: &Preset,
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
masks: Option<&dr_pipeline::mask::MaskStack>,
|
masks: Option<&dr_pipeline::mask::MaskStack>,
|
||||||
|
snapshots: Option<(&[dr_pipeline::Version], &[String])>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = local_sidecar_path(image);
|
let path = local_sidecar_path(image);
|
||||||
|
|
||||||
@@ -369,6 +370,10 @@ pub fn save_local(
|
|||||||
version.revision = version.revision.saturating_add(1);
|
version.revision = version.revision.saturating_add(1);
|
||||||
version.modified = now_secs();
|
version.modified = now_secs();
|
||||||
sidecar.put(version);
|
sidecar.put(version);
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
if let Some((kept, removed)) = snapshots {
|
||||||
|
sidecar.replace_snapshots(&uuid, kept.to_vec(), removed);
|
||||||
|
}
|
||||||
|
|
||||||
let text = sidecar.to_text();
|
let text = sidecar.to_text();
|
||||||
|
|
||||||
@@ -401,9 +406,9 @@ pub fn save_open_edit(
|
|||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
library: &Rc<library_ui::LibraryController>,
|
library: &Rc<library_ui::LibraryController>,
|
||||||
) {
|
) {
|
||||||
// Both taken in one borrow: they are one edit, and a mask stack captured
|
// All taken in one borrow: they are one edit, and a mask stack captured
|
||||||
// from a session that had already moved on would be a different image's.
|
// from a session that had already moved on would be a different image's.
|
||||||
let Some((preset, masks, film)) = session.borrow().as_ref().map(|s| {
|
let Some((preset, masks, film, snapshots, removed)) = session.borrow().as_ref().map(|s| {
|
||||||
(
|
(
|
||||||
s.copy_settings(),
|
s.copy_settings(),
|
||||||
// TRACES: FR-DEV-3
|
// TRACES: FR-DEV-3
|
||||||
@@ -429,6 +434,12 @@ pub fn save_open_edit(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// The snapshots as they stand and the ids deleted this sitting,
|
||||||
|
// so the file gains what was taken and loses what was removed —
|
||||||
|
// and keeps what another device added meanwhile.
|
||||||
|
s.snapshots().to_vec(),
|
||||||
|
s.removed_snapshots().to_vec(),
|
||||||
)
|
)
|
||||||
}) else {
|
}) else {
|
||||||
return;
|
return;
|
||||||
@@ -443,7 +454,13 @@ pub fn save_open_edit(
|
|||||||
// `Everything`: this is the image's *own* edit being written back,
|
// `Everything`: this is the image's *own* edit being written back,
|
||||||
// not a paste onto someone else's. Excluding framing here would
|
// not a paste onto someone else's. Excluding framing here would
|
||||||
// make a crop the one adjustment that never survived a restart.
|
// make a crop the one adjustment that never survived a restart.
|
||||||
if let Err(e) = save_local(path, &preset, Scope::everything(), Some(&masks)) {
|
if let Err(e) = save_local(
|
||||||
|
path,
|
||||||
|
&preset,
|
||||||
|
Scope::everything(),
|
||||||
|
Some(&masks),
|
||||||
|
Some((&snapshots, &removed)),
|
||||||
|
) {
|
||||||
log::warn!("saving {}: {e}", path.display());
|
log::warn!("saving {}: {e}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,6 +478,7 @@ pub fn save_open_edit(
|
|||||||
// sliders were written and every mask was dropped.
|
// sliders were written and every mask was dropped.
|
||||||
masks: Some(masks),
|
masks: Some(masks),
|
||||||
film: Some(film),
|
film: Some(film),
|
||||||
|
snapshots: Some((snapshots, removed)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
library_ui::start_sidecar_writes(window, library, vec![write]);
|
library_ui::start_sidecar_writes(window, library, vec![write]);
|
||||||
@@ -536,6 +554,15 @@ pub fn apply_stored_edit(
|
|||||||
let mut slot = session.borrow_mut();
|
let mut slot = session.borrow_mut();
|
||||||
let Some(s) = slot.as_mut() else { return false };
|
let Some(s) = slot.as_mut() else { return false };
|
||||||
s.apply_version(version);
|
s.apply_version(version);
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// And the snapshots taken of it, on this device or another.
|
||||||
|
s.set_snapshots(
|
||||||
|
sidecar
|
||||||
|
.snapshots_of(&version.uuid)
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::sync_rows(window, rows, session);
|
crate::sync_rows(window, rows, session);
|
||||||
@@ -1162,6 +1189,7 @@ mod tests {
|
|||||||
&Preset::capture(&graph),
|
&Preset::capture(&graph),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
Some(graph.masks()),
|
Some(graph.masks()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1209,6 +1237,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1242,6 +1271,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
save_local(
|
save_local(
|
||||||
@@ -1249,6 +1279,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1267,6 +1298,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let first = load_local(&image)
|
let first = load_local(&image)
|
||||||
@@ -1279,6 +1311,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let second = load_local(&image)
|
let second = load_local(&image)
|
||||||
@@ -1313,6 +1346,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1335,7 +1369,8 @@ mod tests {
|
|||||||
&image,
|
&image,
|
||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.is_err());
|
.is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1353,6 +1388,7 @@ mod tests {
|
|||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::everything(),
|
Scope::everything(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1638,4 +1674,60 @@ mod tests {
|
|||||||
"the failed overwrite kept the new value"
|
"the failed overwrite kept the new value"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// A snapshot goes into the file beside the edit and comes back out as a
|
||||||
|
/// snapshot of it; deleting one takes it out of the file; and neither
|
||||||
|
/// touches the edit the photograph opens at.
|
||||||
|
#[test]
|
||||||
|
fn snapshots_survive_the_local_sidecar_and_leave_it_when_deleted() {
|
||||||
|
let dir = tempdir("snapshots");
|
||||||
|
let image = dir.join("IMG_0001.CR3");
|
||||||
|
std::fs::write(&image, b"").unwrap();
|
||||||
|
|
||||||
|
let graph = edited();
|
||||||
|
let mut liked = EditGraph::default_chain();
|
||||||
|
liked.set_param(
|
||||||
|
dr_pipeline::ops::exposure::ID,
|
||||||
|
dr_pipeline::ops::exposure::EXPOSURE,
|
||||||
|
1.5,
|
||||||
|
);
|
||||||
|
let snapshot = dr_pipeline::Version::from_graph("snap-1", "Brighter", &liked);
|
||||||
|
|
||||||
|
save_local(
|
||||||
|
&image,
|
||||||
|
&Preset::capture(&graph),
|
||||||
|
Scope::everything(),
|
||||||
|
None,
|
||||||
|
Some((&[snapshot], &[])),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
|
||||||
|
let sidecar = Sidecar::parse(&text).unwrap();
|
||||||
|
let edit = sidecar.default_version().expect("the edit");
|
||||||
|
assert!(!edit.is_snapshot(), "the photograph opens at its edit");
|
||||||
|
let back = sidecar.snapshots_of(&edit.uuid);
|
||||||
|
assert_eq!(back.len(), 1);
|
||||||
|
assert_eq!(back[0].name, "Brighter");
|
||||||
|
assert_eq!(
|
||||||
|
back[0]
|
||||||
|
.params
|
||||||
|
.get(&("exposure".to_string(), "exposure".to_string())),
|
||||||
|
Some(&1.5)
|
||||||
|
);
|
||||||
|
|
||||||
|
save_local(
|
||||||
|
&image,
|
||||||
|
&Preset::capture(&graph),
|
||||||
|
Scope::everything(),
|
||||||
|
None,
|
||||||
|
Some((&[], &["snap-1".to_string()])),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
|
||||||
|
let sidecar = Sidecar::parse(&text).unwrap();
|
||||||
|
let edit = sidecar.default_version().expect("the edit is still there");
|
||||||
|
assert!(sidecar.snapshots_of(&edit.uuid).is_empty(), "{text}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ pub struct SettingsStore {
|
|||||||
impl SettingsStore {
|
impl SettingsStore {
|
||||||
/// Open the store at the platform config location.
|
/// Open the store at the platform config location.
|
||||||
///
|
///
|
||||||
/// Linux: `$XDG_CONFIG_HOME/darkroom/settings.json`, falling back to
|
/// **The same resolution `SessionStore` makes**, by calling the same
|
||||||
/// `~/.config` (FR-PLAT-LIN-1) — the same resolution `SessionStore` does,
|
/// function, so `settings.json` sits beside `sessions.json` on every
|
||||||
/// so the two files sit together and a user backing up one takes both.
|
/// platform. That is `dr_plat::dirs`' config directory on a desktop and
|
||||||
|
/// the directory the entry point declared on Android — the second half
|
||||||
|
/// was the bug: this used to read `XDG_CONFIG_HOME` and `HOME` itself,
|
||||||
|
/// neither exists on Android, and the result was `.config/darkroom`
|
||||||
|
/// relative to a working directory of `/`. Every settings edit on a
|
||||||
|
/// tablet failed with "read-only file system", and the page said so
|
||||||
|
/// without saying why.
|
||||||
pub fn open() -> Self {
|
pub fn open() -> Self {
|
||||||
let dir = std::env::var_os("XDG_CONFIG_HOME")
|
Self::open_at(dr_sync::account::config_dir().join("settings.json"))
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
|
|
||||||
})
|
|
||||||
.join("darkroom");
|
|
||||||
Self::open_at(dir.join("settings.json"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open at an explicit path — for tests, and for a non-default location.
|
/// Open at an explicit path — for tests, and for a non-default location.
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
|||||||
window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
|
window.set_settings_thumbnail_budget(budget::label(s.cache.thumbnail_budget_bytes).into());
|
||||||
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
|
window.set_settings_thumbnail_unlimited(s.cache.thumbnail_budget_bytes.is_none());
|
||||||
window.set_settings_keep_opened(s.cache.keep_opened_originals);
|
window.set_settings_keep_opened(s.cache.keep_opened_originals);
|
||||||
|
window.set_settings_write_xmp(s.library.write_xmp_sidecars);
|
||||||
|
|
||||||
// --- develop -------------------------------------------------------
|
// --- develop -------------------------------------------------------
|
||||||
//
|
//
|
||||||
@@ -501,6 +502,16 @@ pub fn wire<F, G>(
|
|||||||
render(&w, &ctl);
|
render(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = controller.clone();
|
||||||
|
window.on_settings_write_xmp_toggled(move |on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
ctl.edit(|s| s.library.write_xmp_sidecars = on);
|
||||||
|
render(&w, &ctl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- faces ---------------------------------------------------------
|
// --- faces ---------------------------------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
//! TRACES: FR-CAT-13 | FR-NC-9 | NFR-R4
|
||||||
|
//! Standard XMP sidecars, read into the catalog and written back out of it.
|
||||||
|
//!
|
||||||
|
//! `dr-xmp` reads and writes the file. This is the other half the requirement
|
||||||
|
//! asks for and the half `docs/outstanding.md` called "the larger": which
|
||||||
|
//! images a given `.xmp` describes, what the catalog holds about them, how
|
||||||
|
//! the two are reconciled, where a disagreement goes, and the write in the
|
||||||
|
//! other direction. Nothing here parses XML and nothing in `dr-xmp` knows a
|
||||||
|
//! catalog exists.
|
||||||
|
//!
|
||||||
|
//! # Two conventions for one file
|
||||||
|
//!
|
||||||
|
//! Lightroom writes `IMG_0001.xmp` beside `IMG_0001.CR3`; darktable writes
|
||||||
|
//! `IMG_0001.CR3.xmp`. Both are answered by [`images_for`]: the path with
|
||||||
|
//! `.xmp` taken off is tried as an image path first, exactly, and failing that
|
||||||
|
//! as a stem shared with the images beside it — the rule DarkRoom's own
|
||||||
|
//! sidecar already follows, under which a RAW and the JPEG the camera wrote
|
||||||
|
//! beside it are one photograph (FR-CAT-11) and share the document.
|
||||||
|
//!
|
||||||
|
//! # Precedence, and where a disagreement goes
|
||||||
|
//!
|
||||||
|
//! A standard XMP carries no revision and no device, so nothing in it can say
|
||||||
|
//! whether its rating is newer than the catalog's. The automatic pull
|
||||||
|
//! therefore runs [`dr_xmp::reconcile`] with the catalog winning: keywords
|
||||||
|
//! union, and every whole-valued field is taken only where the catalog holds
|
||||||
|
//! none. A genuine disagreement — both sides hold a value, and different ones —
|
||||||
|
//! is written to `xmp_conflicts` rather than resolved, and the requirement's
|
||||||
|
//! "a metadata reload offered" is that table with a button in front of it.
|
||||||
|
//! [`reload`] is the button: the same reconciliation with the sidecar winning,
|
||||||
|
//! asked for by a person.
|
||||||
|
//!
|
||||||
|
//! # Detection
|
||||||
|
//!
|
||||||
|
//! "External modification of an XMP sidecar shall be detected." The scan
|
||||||
|
//! already records the ETag of every sidecar it has taken in, and fetches
|
||||||
|
//! only those whose ETag has moved. An `.xmp` edited in another application
|
||||||
|
//! is precisely a file whose ETag has moved, so the detection is the pull's
|
||||||
|
//! ordinary incrementality — nothing watches a directory, and nothing needs
|
||||||
|
//! to. What is new is what happens after: the re-read reconciles again, and
|
||||||
|
//! the second reading is where a conflict first appears.
|
||||||
|
//!
|
||||||
|
//! # Writing, and why it is off
|
||||||
|
//!
|
||||||
|
//! NFR-R4 makes source-adjacent writes opt-in. A library shared with another
|
||||||
|
//! editor is one where a file DarkRoom wrote can be read by something else,
|
||||||
|
//! and that is exactly what [`Xmp::rewrite`] is built for — it rewrites only
|
||||||
|
//! the properties DarkRoom owns and copies everything else through byte for
|
||||||
|
//! byte. But the option to write beside somebody's originals is theirs to
|
||||||
|
//! switch on, and until they do the catalog and DarkRoom's own sidecar are the
|
||||||
|
//! only things a judgement reaches.
|
||||||
|
|
||||||
|
use dr_catalog::Catalog;
|
||||||
|
use dr_types::{FlagState, ImageId};
|
||||||
|
use dr_xmp::{Precedence, Rating, Xmp};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// The two places an image's XMP sidecar may be, in the order they are
|
||||||
|
/// tried when writing: the darktable spelling first, because it names the
|
||||||
|
/// image unambiguously, then Lightroom's.
|
||||||
|
///
|
||||||
|
/// When reading, whichever the scan found is the one read; this is for the
|
||||||
|
/// write, which has to choose. An existing file of either spelling is
|
||||||
|
/// rewritten in place, and a photograph with neither gets Lightroom's, since
|
||||||
|
/// it is the spelling more applications look for.
|
||||||
|
pub fn candidate_paths(image_path: &str) -> [String; 2] {
|
||||||
|
let stem = match image_path.rsplit_once('.') {
|
||||||
|
Some((stem, ext)) if !ext.contains('/') => stem,
|
||||||
|
_ => image_path,
|
||||||
|
};
|
||||||
|
[format!("{image_path}.xmp"), format!("{stem}.xmp")]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a listing entry is a standard XMP sidecar rather than DarkRoom's.
|
||||||
|
pub fn is_xmp(path: &str) -> bool {
|
||||||
|
path.rsplit_once('.')
|
||||||
|
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case(dr_sync::scan::XMP_EXTENSION))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The images an `.xmp` at `path` describes: their ids and default versions.
|
||||||
|
///
|
||||||
|
/// Empty when the sidecar sits beside nothing this catalog knows, which is
|
||||||
|
/// the ordinary case for a file that arrived before its photograph was
|
||||||
|
/// scanned — the next pull reads it again, because no ETag is recorded for a
|
||||||
|
/// sidecar that reached nothing.
|
||||||
|
pub fn images_for(conn: &Connection, root_id: i64, path: &str) -> Vec<(ImageId, i64)> {
|
||||||
|
let Some(named) = path
|
||||||
|
.strip_suffix(".xmp")
|
||||||
|
.or_else(|| path.strip_suffix(".XMP"))
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids = |sql: &str, arg: &str| -> Vec<ImageId> {
|
||||||
|
let Ok(mut stmt) = conn.prepare(sql) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
stmt.query_map(rusqlite::params![root_id, arg], |r| r.get::<_, i64>(0))
|
||||||
|
.map(|rows| rows.flatten().map(|i| ImageId(i as u64)).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// darktable's spelling: the name before `.xmp` is the image itself.
|
||||||
|
let mut images = ids(
|
||||||
|
"SELECT id FROM images WHERE root_id = ?1 AND source_ref = ?2",
|
||||||
|
named,
|
||||||
|
);
|
||||||
|
if images.is_empty() {
|
||||||
|
// Lightroom's: a stem shared with the photograph, and with the JPEG
|
||||||
|
// beside it. The escape is what makes `%` and `_` in a folder name
|
||||||
|
// literal, and the Rust-side check is what actually decides — a LIKE
|
||||||
|
// is a filter, not an answer.
|
||||||
|
let prefix = named
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_");
|
||||||
|
let candidates: Vec<(ImageId, String)> = {
|
||||||
|
let Ok(mut stmt) = conn.prepare(
|
||||||
|
"SELECT id, source_ref FROM images
|
||||||
|
WHERE root_id = ?1 AND source_ref LIKE ?2 ESCAPE '\\'",
|
||||||
|
) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
stmt.query_map(rusqlite::params![root_id, format!("{prefix}.%")], |r| {
|
||||||
|
Ok((ImageId(r.get::<_, i64>(0)? as u64), r.get::<_, String>(1)?))
|
||||||
|
})
|
||||||
|
.map(|rows| rows.flatten().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
images = candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, source)| candidate_paths(source)[1].eq_ignore_ascii_case(path))
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
images
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|image| {
|
||||||
|
let version = dr_catalog::rating::default_version_id(conn, image).ok()?;
|
||||||
|
Some((image, version))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the catalog holds about one image, as the sidecar would carry it.
|
||||||
|
///
|
||||||
|
/// Rating and flag are two axes here and one field there: a rejection is
|
||||||
|
/// written as Adobe's `-1` and a rating as its stars, and a frame that is
|
||||||
|
/// both rejected and starred loses the stars in the file — the loss the
|
||||||
|
/// format has and `dr_xmp::Rating` records. Reading goes the other way in
|
||||||
|
/// [`apply`].
|
||||||
|
pub fn record_of(conn: &Connection, image: ImageId, version: i64) -> Xmp {
|
||||||
|
let mut xmp = Xmp::default();
|
||||||
|
let row = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT rating, flag, label FROM versions WHERE id = ?1",
|
||||||
|
[version],
|
||||||
|
|r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, i64>(0)?,
|
||||||
|
r.get::<_, i64>(1)?,
|
||||||
|
r.get::<_, Option<i64>>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
if let Some((rating, flag, label)) = row {
|
||||||
|
xmp.rating = if flag == flag_code(FlagState::Reject) {
|
||||||
|
Some(Rating::Rejected)
|
||||||
|
} else if rating > 0 {
|
||||||
|
Some(Rating::Stars(rating.clamp(0, 5) as u8))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
xmp.set_colour(dr_catalog::rating::label_from_code(label));
|
||||||
|
}
|
||||||
|
xmp.keywords = dr_catalog::keywords::for_image(conn, image).unwrap_or_default();
|
||||||
|
xmp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a reconciled record onto one image.
|
||||||
|
///
|
||||||
|
/// Only what the record holds: a `None` rating is *unrated* and is not
|
||||||
|
/// written over a star, for the reason `dr_pipeline::sidecar::merge_judgement`
|
||||||
|
/// gives — a file that says nothing cannot erase an afternoon's culling. A
|
||||||
|
/// rejection sets the flag and leaves the stars alone; stars set the rating
|
||||||
|
/// and, if the frame was rejected, lift the rejection, since the file said
|
||||||
|
/// it was worth a number. Keywords are added and never removed here, which
|
||||||
|
/// is what a union means. `label` is set where the file names one of the
|
||||||
|
/// five colours.
|
||||||
|
///
|
||||||
|
/// Returns whether any column moved.
|
||||||
|
pub fn apply(
|
||||||
|
conn: &Connection,
|
||||||
|
image: ImageId,
|
||||||
|
version: i64,
|
||||||
|
record: &Xmp,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let mut changed = false;
|
||||||
|
match record.rating {
|
||||||
|
Some(Rating::Rejected) => {
|
||||||
|
changed |= conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE versions SET flag = ?2 WHERE id = ?1 AND flag <> ?2",
|
||||||
|
rusqlite::params![version, flag_code(FlagState::Reject)],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
> 0;
|
||||||
|
}
|
||||||
|
Some(Rating::Stars(n)) if n > 0 => {
|
||||||
|
changed |= conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE versions
|
||||||
|
SET rating = ?2,
|
||||||
|
flag = CASE WHEN flag = ?3 THEN 0 ELSE flag END
|
||||||
|
WHERE id = ?1 AND (rating <> ?2 OR flag = ?3)",
|
||||||
|
rusqlite::params![version, n.min(5) as i64, flag_code(FlagState::Reject)],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
> 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(colour) = record.colour() {
|
||||||
|
changed |= conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE versions SET label = ?2 WHERE id = ?1 AND label IS NOT ?2",
|
||||||
|
rusqlite::params![version, dr_catalog::rating::label_code(colour)],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
> 0;
|
||||||
|
}
|
||||||
|
if !record.keywords.is_empty() {
|
||||||
|
let have = dr_catalog::keywords::for_image(conn, image).unwrap_or_default();
|
||||||
|
for word in &record.keywords {
|
||||||
|
if have.iter().any(|h| h.eq_ignore_ascii_case(word)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match dr_catalog::keywords::assign(conn, &[image], word) {
|
||||||
|
Ok(n) => changed |= n > 0,
|
||||||
|
// A word the vocabulary refuses — empty once trimmed, or a
|
||||||
|
// separator on its own — costs that word and not the file.
|
||||||
|
Err(e) => log::debug!("xmp keyword {word:?} on {}: {e}", image.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// Before rewriting an existing sidecar, keep what the catalog cannot hold.
|
||||||
|
///
|
||||||
|
/// `Xmp::rewrite` replaces the owned properties wholesale — that is what
|
||||||
|
/// makes "no rating" mean the rating goes — and the catalog has columns for
|
||||||
|
/// three of them: rating and flag, label, keywords. A record built from the
|
||||||
|
/// catalog therefore says nothing about a title, a caption, a copyright
|
||||||
|
/// line or a hierarchical subject, and writing it as it stands would delete
|
||||||
|
/// all four from a sidecar Lightroom wrote, on every judgement. So those
|
||||||
|
/// come through from the file, and so does a label that is not one of the
|
||||||
|
/// five colours — the catalog kept no text for it, and the file's is the
|
||||||
|
/// only copy.
|
||||||
|
pub fn carry_through(record: &mut Xmp, existing: &Xmp) {
|
||||||
|
record.hierarchical_subjects = existing.hierarchical_subjects.clone();
|
||||||
|
record.title = existing.title.clone();
|
||||||
|
record.description = existing.description.clone();
|
||||||
|
record.creators = existing.creators.clone();
|
||||||
|
record.copyright = existing.copyright.clone();
|
||||||
|
record.credit = existing.credit.clone();
|
||||||
|
record.usage_terms = existing.usage_terms.clone();
|
||||||
|
if record.label.is_none() && existing.colour().is_none() {
|
||||||
|
record.label = existing.label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one sidecar did when taken in.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TakenIn {
|
||||||
|
/// Images whose rows moved.
|
||||||
|
pub changed: usize,
|
||||||
|
/// Images the sidecar described at all. Zero means it sits beside nothing
|
||||||
|
/// this catalog has yet, and its ETag must not be recorded.
|
||||||
|
pub described: usize,
|
||||||
|
/// The fields both sides held and disagreed on, across every image.
|
||||||
|
pub conflicts: Vec<dr_xmp::Field>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13 | FR-NC-9
|
||||||
|
/// Take a standard sidecar into the catalog, with the catalog winning.
|
||||||
|
///
|
||||||
|
/// The automatic path. Every image the file describes is reconciled against
|
||||||
|
/// what the catalog holds for it; the merged record is applied; and a
|
||||||
|
/// disagreement is recorded in `xmp_conflicts` for a person to settle, never
|
||||||
|
/// resolved here.
|
||||||
|
pub fn take_in(
|
||||||
|
conn: &Connection,
|
||||||
|
root_id: i64,
|
||||||
|
path: &str,
|
||||||
|
text: &str,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<TakenIn, String> {
|
||||||
|
let sidecar = Xmp::parse(text).map_err(|e| e.to_string())?;
|
||||||
|
let mut out = TakenIn::default();
|
||||||
|
for (image, version) in images_for(conn, root_id, path) {
|
||||||
|
out.described += 1;
|
||||||
|
let mine = record_of(conn, image, version);
|
||||||
|
let reconciled = dr_xmp::reconcile(&mine, &sidecar, Precedence::Catalog);
|
||||||
|
if apply(conn, image, version, &reconciled.merged)? {
|
||||||
|
out.changed += 1;
|
||||||
|
}
|
||||||
|
for field in reconciled.conflicts {
|
||||||
|
if !out.conflicts.contains(&field) {
|
||||||
|
out.conflicts.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.conflicts.is_empty() {
|
||||||
|
clear_conflict(conn, root_id, path);
|
||||||
|
} else if out.described > 0 {
|
||||||
|
record_conflict(conn, root_id, path, &out.conflicts, now);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
/// The offered reload: the same reconciliation with the sidecar winning.
|
||||||
|
///
|
||||||
|
/// Asked for by a person, per file, which is the consent the module
|
||||||
|
/// documentation says this needs. Clears the conflict whatever the outcome —
|
||||||
|
/// the person has now seen it, and if the file still disagrees the next pull
|
||||||
|
/// that sees it change will say so again.
|
||||||
|
pub fn reload(conn: &Connection, root_id: i64, path: &str, text: &str) -> Result<TakenIn, String> {
|
||||||
|
let sidecar = Xmp::parse(text).map_err(|e| e.to_string())?;
|
||||||
|
let mut out = TakenIn::default();
|
||||||
|
for (image, version) in images_for(conn, root_id, path) {
|
||||||
|
out.described += 1;
|
||||||
|
let mine = record_of(conn, image, version);
|
||||||
|
let reconciled = dr_xmp::reconcile(&mine, &sidecar, Precedence::Sidecar);
|
||||||
|
// The sidecar wins outright on the whole-valued fields, and that
|
||||||
|
// includes a rating it holds and the catalog's it replaces — which
|
||||||
|
// `apply` does. What `apply` will not do is *clear* a star the file
|
||||||
|
// does not mention, and a reload does not ask it to: the file said
|
||||||
|
// nothing, and nothing is not a judgement.
|
||||||
|
if apply(conn, image, version, &reconciled.merged)? {
|
||||||
|
out.changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clear_conflict(conn, root_id, path);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One outstanding disagreement, for the settings page.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Conflict {
|
||||||
|
pub path: String,
|
||||||
|
pub fields: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every sidecar in this library that disagrees with the catalog.
|
||||||
|
pub fn conflicts(catalog: &Catalog, root_id: i64) -> Vec<Conflict> {
|
||||||
|
let Ok(mut stmt) = catalog
|
||||||
|
.connection()
|
||||||
|
.prepare("SELECT path, fields FROM xmp_conflicts WHERE root_id = ?1 ORDER BY path")
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
stmt.query_map([root_id], |r| {
|
||||||
|
Ok(Conflict {
|
||||||
|
path: r.get(0)?,
|
||||||
|
fields: r.get(1)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|rows| rows.flatten().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_conflict(
|
||||||
|
conn: &Connection,
|
||||||
|
root_id: i64,
|
||||||
|
path: &str,
|
||||||
|
fields: &[dr_xmp::Field],
|
||||||
|
now: i64,
|
||||||
|
) {
|
||||||
|
let fields = fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("{f:?}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let done = conn.execute(
|
||||||
|
"INSERT INTO xmp_conflicts(root_id, path, fields, seen_at) VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(root_id, path) DO UPDATE SET
|
||||||
|
fields = excluded.fields, seen_at = excluded.seen_at",
|
||||||
|
rusqlite::params![root_id, path, fields, now],
|
||||||
|
);
|
||||||
|
if let Err(e) = done {
|
||||||
|
log::debug!("recording xmp conflict for {path}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_conflict(conn: &Connection, root_id: i64, path: &str) {
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM xmp_conflicts WHERE root_id = ?1 AND path = ?2",
|
||||||
|
rusqlite::params![root_id, path],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag_code(flag: FlagState) -> i64 {
|
||||||
|
match flag {
|
||||||
|
FlagState::Unflagged => 0,
|
||||||
|
FlagState::Pick => 1,
|
||||||
|
FlagState::Reject => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn catalog() -> Catalog {
|
||||||
|
let c = Catalog::in_memory().unwrap();
|
||||||
|
c.connection()
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib');
|
||||||
|
INSERT INTO images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (1, 1, 'Photos/IMG_0001.CR3', 0),
|
||||||
|
(2, 1, 'Photos/IMG_0001.JPG', 0),
|
||||||
|
(3, 1, 'Photos/IMG_0002.CR3', 0);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
dr_catalog::rating::ensure_default_versions(c.connection()).unwrap();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHTROOM: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about=""
|
||||||
|
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmp:Rating="4"
|
||||||
|
xmp:Label="Red">
|
||||||
|
<dc:subject><rdf:Bag><rdf:li>puffin</rdf:li><rdf:li>iceland</rdf:li></rdf:Bag></dc:subject>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>"#;
|
||||||
|
|
||||||
|
/// Both spellings reach the photograph, and Lightroom's reaches the JPEG
|
||||||
|
/// beside it too.
|
||||||
|
#[test]
|
||||||
|
fn both_namings_find_their_images() {
|
||||||
|
let c = catalog();
|
||||||
|
let conn = c.connection();
|
||||||
|
let mut lr: Vec<u64> = images_for(conn, 1, "Photos/IMG_0001.xmp")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(i, _)| i.0)
|
||||||
|
.collect();
|
||||||
|
lr.sort();
|
||||||
|
assert_eq!(lr, [1, 2], "the RAW and the JPEG are one photograph");
|
||||||
|
|
||||||
|
let dt: Vec<u64> = images_for(conn, 1, "Photos/IMG_0001.CR3.xmp")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(i, _)| i.0)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(dt, [1], "darktable's names the file exactly");
|
||||||
|
|
||||||
|
assert!(images_for(conn, 1, "Photos/IMG_9999.xmp").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary pull: an empty catalog takes everything the file says.
|
||||||
|
#[test]
|
||||||
|
fn a_sidecar_fills_what_the_catalog_lacks() {
|
||||||
|
let c = catalog();
|
||||||
|
let conn = c.connection();
|
||||||
|
let taken = take_in(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM, 1).unwrap();
|
||||||
|
assert_eq!(taken.described, 1);
|
||||||
|
assert_eq!(taken.changed, 1);
|
||||||
|
assert!(taken.conflicts.is_empty());
|
||||||
|
|
||||||
|
let record = record_of(conn, ImageId(3), 3);
|
||||||
|
assert_eq!(record.rating, Some(Rating::Stars(4)));
|
||||||
|
assert_eq!(record.colour(), Some(dr_types::ColourLabel::Red));
|
||||||
|
assert_eq!(record.keywords, ["iceland", "puffin"]);
|
||||||
|
assert!(conflicts(&c, 1).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rating the catalog already holds is not overwritten by the file,
|
||||||
|
/// the disagreement is recorded, and the reload — a person asking —
|
||||||
|
/// takes the file's.
|
||||||
|
#[test]
|
||||||
|
fn a_disagreement_is_recorded_and_a_reload_settles_it() {
|
||||||
|
let c = catalog();
|
||||||
|
let conn = c.connection();
|
||||||
|
dr_catalog::rating::set_rating(conn, ImageId(3), 2).unwrap();
|
||||||
|
|
||||||
|
let taken = take_in(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM, 1).unwrap();
|
||||||
|
assert_eq!(taken.conflicts, [dr_xmp::Field::Rating]);
|
||||||
|
assert_eq!(
|
||||||
|
record_of(conn, ImageId(3), 3).rating,
|
||||||
|
Some(Rating::Stars(2)),
|
||||||
|
"the catalog's own rating stands"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
record_of(conn, ImageId(3), 3).keywords,
|
||||||
|
["iceland", "puffin"],
|
||||||
|
"while the keywords still union"
|
||||||
|
);
|
||||||
|
let open = conflicts(&c, 1);
|
||||||
|
assert_eq!(open.len(), 1);
|
||||||
|
assert_eq!(open[0].path, "Photos/IMG_0002.xmp");
|
||||||
|
assert_eq!(open[0].fields, "Rating");
|
||||||
|
|
||||||
|
reload(conn, 1, "Photos/IMG_0002.xmp", LIGHTROOM).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
record_of(conn, ImageId(3), 3).rating,
|
||||||
|
Some(Rating::Stars(4))
|
||||||
|
);
|
||||||
|
assert!(conflicts(&c, 1).is_empty(), "settled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rejection and a star count are two axes here and one field there.
|
||||||
|
#[test]
|
||||||
|
fn a_rejection_crosses_as_adobes_minus_one_and_back() {
|
||||||
|
let c = catalog();
|
||||||
|
let conn = c.connection();
|
||||||
|
dr_catalog::rating::set_flag(conn, ImageId(3), FlagState::Reject).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
record_of(conn, ImageId(3), 3).rating,
|
||||||
|
Some(Rating::Rejected)
|
||||||
|
);
|
||||||
|
|
||||||
|
let back = Xmp {
|
||||||
|
rating: Some(Rating::Stars(3)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
apply(conn, ImageId(3), 3, &back).unwrap();
|
||||||
|
let after = record_of(conn, ImageId(3), 3);
|
||||||
|
assert_eq!(
|
||||||
|
after.rating,
|
||||||
|
Some(Rating::Stars(3)),
|
||||||
|
"the stars lift the rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A judgement written over a Lightroom sidecar must not cost the caption
|
||||||
|
/// Lightroom wrote, since the catalog never held it.
|
||||||
|
#[test]
|
||||||
|
fn a_rewrite_keeps_what_the_catalog_has_no_column_for() {
|
||||||
|
let theirs = Xmp {
|
||||||
|
title: Some("Puffins at dusk".into()),
|
||||||
|
copyright: Some("© Someone".into()),
|
||||||
|
hierarchical_subjects: vec!["Places|Iceland".into()],
|
||||||
|
label: Some("Second choice".into()),
|
||||||
|
rating: Some(Rating::Stars(1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ours = Xmp {
|
||||||
|
rating: Some(Rating::Stars(4)),
|
||||||
|
keywords: vec!["puffin".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
carry_through(&mut ours, &theirs);
|
||||||
|
|
||||||
|
assert_eq!(ours.rating, Some(Rating::Stars(4)), "the judgement is ours");
|
||||||
|
assert_eq!(ours.title.as_deref(), Some("Puffins at dusk"));
|
||||||
|
assert_eq!(ours.copyright.as_deref(), Some("© Someone"));
|
||||||
|
assert_eq!(ours.hierarchical_subjects, ["Places|Iceland"]);
|
||||||
|
assert_eq!(
|
||||||
|
ours.label.as_deref(),
|
||||||
|
Some("Second choice"),
|
||||||
|
"a label that is not one of the five colours has no other copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut red = Xmp::default();
|
||||||
|
red.set_colour(Some(dr_types::ColourLabel::Red));
|
||||||
|
carry_through(&mut red, &theirs);
|
||||||
|
assert_eq!(
|
||||||
|
red.label.as_deref(),
|
||||||
|
Some("Red"),
|
||||||
|
"but a colour of ours wins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two spellings a write chooses between.
|
||||||
|
#[test]
|
||||||
|
fn the_write_tries_darktables_spelling_then_lightrooms() {
|
||||||
|
assert_eq!(
|
||||||
|
candidate_paths("Photos/IMG_0001.CR3"),
|
||||||
|
["Photos/IMG_0001.CR3.xmp", "Photos/IMG_0001.xmp"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
candidate_paths("a.b/IMG"),
|
||||||
|
["a.b/IMG.xmp", "a.b/IMG.xmp"],
|
||||||
|
"a dot in a folder is not an extension"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -995,6 +995,18 @@ export global Adjustments {
|
|||||||
|
|
||||||
callback param-changed(int, int, float);
|
callback param-changed(int, int, float);
|
||||||
callback param-reset(int, int);
|
callback param-reset(int, int);
|
||||||
|
/// TRACES: FR-DEV-16
|
||||||
|
/// The control last moved, so a key can put it back.
|
||||||
|
///
|
||||||
|
/// The generated rows have no focus: they are a model the repeater
|
||||||
|
/// rebuilds, and a focus ring for them would be a larger change than the
|
||||||
|
/// binding wants. What a photographer means by "reset that" from the
|
||||||
|
/// keyboard is the slider they just dragged too far, and that is a thing
|
||||||
|
/// the panel can remember without any of them being focused. -1 is none,
|
||||||
|
/// which is where every photograph starts — see `lib.rs`, which clears
|
||||||
|
/// these on open so the key cannot reach back into the previous edit.
|
||||||
|
in-out property <int> touched-op: -1;
|
||||||
|
in-out property <int> touched-param: -1;
|
||||||
/// Every parameter of one operation back to its default. A section's reset
|
/// Every parameter of one operation back to its default. A section's reset
|
||||||
/// and a curve's reset are the same action, so they share the one callback
|
/// and a curve's reset are the same action, so they share the one callback
|
||||||
/// rather than duplicating a handler that would have to be kept in step
|
/// rather than duplicating a handler that would have to be kept in step
|
||||||
@@ -1511,6 +1523,8 @@ export component AdjustPanel inherits Rectangle {
|
|||||||
curve-channel: Adjustments.curve-channel;
|
curve-channel: Adjustments.curve-channel;
|
||||||
drag-changed(on) => { root.slider-dragging = on; }
|
drag-changed(on) => { root.slider-dragging = on; }
|
||||||
param-changed(op, param, v) => {
|
param-changed(op, param, v) => {
|
||||||
|
Adjustments.touched-op = op;
|
||||||
|
Adjustments.touched-param = param;
|
||||||
Adjustments.param-changed(op, param, v);
|
Adjustments.param-changed(op, param, v);
|
||||||
}
|
}
|
||||||
param-reset(op, param) => { Adjustments.param-reset(op, param); }
|
param-reset(op, param) => { Adjustments.param-reset(op, param); }
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ export component AppWindow inherits Window {
|
|||||||
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||||||
/// the binary it is part of.
|
/// the binary it is part of.
|
||||||
in property <string> app-version: "unknown";
|
in property <string> app-version: "unknown";
|
||||||
|
/// TRACES: NFR-OPS-1
|
||||||
|
/// The diagnostics bundle's preview and the last save's outcome — see
|
||||||
|
/// `SettingsPage`, which is the only reader.
|
||||||
|
in property <string> diagnostics-preview;
|
||||||
|
in property <string> diagnostics-result;
|
||||||
|
callback diagnostics-prepare();
|
||||||
|
callback diagnostics-save();
|
||||||
|
callback diagnostics-discard();
|
||||||
|
|
||||||
// Current image, for the status strip and empty state.
|
// Current image, for the status strip and empty state.
|
||||||
in property <string> filename: "";
|
in property <string> filename: "";
|
||||||
@@ -838,6 +846,11 @@ export component AppWindow inherits Window {
|
|||||||
in property <[string]> settings-timeline-bar-labels;
|
in property <[string]> settings-timeline-bar-labels;
|
||||||
in property <int> settings-timeline-bars-selected: 0;
|
in property <int> settings-timeline-bars-selected: 0;
|
||||||
callback settings-timeline-bars-picked(int);
|
callback settings-timeline-bars-picked(int);
|
||||||
|
/// TRACES: FR-CAT-13
|
||||||
|
in property <bool> settings-write-xmp: false;
|
||||||
|
callback settings-write-xmp-toggled(bool);
|
||||||
|
in property <int> settings-xmp-conflicts: 0;
|
||||||
|
callback settings-xmp-reload();
|
||||||
|
|
||||||
/// TRACES: FR-UI-1
|
/// TRACES: FR-UI-1
|
||||||
/// The group-navigation preference, and what "Automatic" resolves to here.
|
/// The group-navigation preference, and what "Automatic" resolves to here.
|
||||||
@@ -1273,6 +1286,10 @@ in property <bool> panel-visible: true;
|
|||||||
timeline-bar-labels: root.settings-timeline-bar-labels;
|
timeline-bar-labels: root.settings-timeline-bar-labels;
|
||||||
timeline-bars-selected: root.settings-timeline-bars-selected;
|
timeline-bars-selected: root.settings-timeline-bars-selected;
|
||||||
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
|
timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); }
|
||||||
|
write-xmp: root.settings-write-xmp;
|
||||||
|
write-xmp-toggled(on) => { root.settings-write-xmp-toggled(on); }
|
||||||
|
xmp-conflicts: root.settings-xmp-conflicts;
|
||||||
|
xmp-reload() => { root.settings-xmp-reload(); }
|
||||||
cache-usage: root.settings-cache-usage;
|
cache-usage: root.settings-cache-usage;
|
||||||
|
|
||||||
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
|
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
|
||||||
@@ -1363,6 +1380,13 @@ in property <bool> panel-visible: true;
|
|||||||
thumbnail-library() => { root.library-thumbnail-all(); }
|
thumbnail-library() => { root.library-thumbnail-all(); }
|
||||||
index-faces() => { root.identity-index(); }
|
index-faces() => { root.identity-index(); }
|
||||||
|
|
||||||
|
// TRACES: NFR-OPS-1
|
||||||
|
diagnostics-preview: root.diagnostics-preview;
|
||||||
|
diagnostics-result: root.diagnostics-result;
|
||||||
|
diagnostics-prepare() => { root.diagnostics-prepare(); }
|
||||||
|
diagnostics-save() => { root.diagnostics-save(); }
|
||||||
|
diagnostics-discard() => { root.diagnostics-discard(); }
|
||||||
|
|
||||||
close() => { root.settings-close(); }
|
close() => { root.settings-close(); }
|
||||||
reset-defaults() => { root.settings-reset(); }
|
reset-defaults() => { root.settings-reset(); }
|
||||||
}
|
}
|
||||||
@@ -2497,6 +2521,28 @@ in property <bool> panel-visible: true;
|
|||||||
root.inspect-toggled(-1, -1);
|
root.inspect-toggled(-1, -1);
|
||||||
return accept;
|
return accept;
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-16
|
||||||
|
// R puts back the control last moved. Bare, like
|
||||||
|
// Z: the modifier chords above have all returned.
|
||||||
|
// Nothing has been moved on this photograph until
|
||||||
|
// something has, so the key is silent until then
|
||||||
|
// rather than resetting a control nobody named.
|
||||||
|
if ((event.text == "r" || event.text == "R")
|
||||||
|
&& Adjustments.touched-op >= 0) {
|
||||||
|
Adjustments.param-reset(
|
||||||
|
Adjustments.touched-op,
|
||||||
|
Adjustments.touched-param);
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
// TRACES: FR-DEV-16
|
||||||
|
// H hides or shows the selected mask layer — the
|
||||||
|
// ring at the head of its row, from the keyboard.
|
||||||
|
// With nothing selected there is nothing to hide,
|
||||||
|
// and Rust says so by doing nothing.
|
||||||
|
if (event.text == "h" || event.text == "H") {
|
||||||
|
Masking.selected-toggled();
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
// GESTURE: Move to the next or previous photograph
|
// GESTURE: Move to the next or previous photograph
|
||||||
// where: Develop
|
// where: Develop
|
||||||
// touch: Tap a frame in the roll along the
|
// touch: Tap a frame in the roll along the
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export component SliderTrack inherits Rectangle {
|
|||||||
// where: Develop
|
// where: Develop
|
||||||
// touch: Double-tap its track
|
// touch: Double-tap its track
|
||||||
// pointer: Double-click its track, or right-click it
|
// pointer: Double-click its track, or right-click it
|
||||||
|
// keys: R, for the control last moved
|
||||||
// why: The column is 280px wide and the colour mixer alone
|
// why: The column is 280px wide and the colour mixer alone
|
||||||
// puts thirty-six of these in it, so a reset button per
|
// puts thirty-six of these in it, so a reset button per
|
||||||
// row would be most of the width. Two ways in with a
|
// row would be most of the width. Two ways in with a
|
||||||
|
|||||||
+152
-1
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Develop } from "session.slint";
|
import { Develop } from "session.slint";
|
||||||
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
|
import { PanelHeading, Caption, Label, Value, Button, Field } from "widgets.slint";
|
||||||
|
|
||||||
// One step, flattened for Slint's model system.
|
// One step, flattened for Slint's model system.
|
||||||
export struct HistoryRow {
|
export struct HistoryRow {
|
||||||
@@ -40,6 +40,105 @@ export struct HistoryRow {
|
|||||||
undone: bool,
|
undone: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// One named snapshot of the open edit.
|
||||||
|
export struct SnapshotRow {
|
||||||
|
/// Routing back to the core. Opaque to this file.
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
/// The canvas is showing this one instead of the edit, while held.
|
||||||
|
comparing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5 | FR-DEV-7
|
||||||
|
/// A snapshot: press it to go back to it, hold the eye beside it to look.
|
||||||
|
component SnapshotItem inherits Rectangle {
|
||||||
|
in property <SnapshotRow> data;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
callback restored();
|
||||||
|
/// Both edges, like "Before": down shows the snapshot, up shows the edit.
|
||||||
|
callback compared(bool);
|
||||||
|
callback removed();
|
||||||
|
|
||||||
|
height: Theme.row-height;
|
||||||
|
background: root.data.comparing
|
||||||
|
? Theme.selected
|
||||||
|
: (touch.has-hover ? Theme.hover : transparent);
|
||||||
|
|
||||||
|
// Behind the two buttons, so a press on either does its own job and a
|
||||||
|
// press anywhere else on the row is the restore.
|
||||||
|
touch := TouchArea {
|
||||||
|
width: 100%;
|
||||||
|
height: max(parent.height, Theme.touch-target);
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
enabled: root.enabled;
|
||||||
|
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||||
|
clicked => { root.restored(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: Theme.gap-sm;
|
||||||
|
padding-right: Theme.gap-sm;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.data.name;
|
||||||
|
emphasised: root.data.comparing || touch.has-hover;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
overflow: elide;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GESTURE: See the photograph as a snapshot had it
|
||||||
|
// where: Develop
|
||||||
|
// touch: Press and hold the eye beside the snapshot
|
||||||
|
// pointer: Press and hold the eye beside the snapshot
|
||||||
|
// why: The same hold as "Before", against a point the
|
||||||
|
// photographer chose rather than the file: "the version
|
||||||
|
// I liked twenty minutes ago" is how a choice between two
|
||||||
|
// treatments is actually made. It takes no history step
|
||||||
|
// and changes nothing; letting go puts the edit back.
|
||||||
|
//
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
eye := TouchArea {
|
||||||
|
width: Theme.touch-target;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
enabled: root.enabled;
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.down
|
||||||
|
&& event.button == PointerEventButton.left {
|
||||||
|
root.compared(true);
|
||||||
|
}
|
||||||
|
if event.kind == PointerEventKind.up
|
||||||
|
|| event.kind == PointerEventKind.cancel {
|
||||||
|
root.compared(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "◐";
|
||||||
|
emphasised: eye.has-hover || root.data.comparing;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cut := TouchArea {
|
||||||
|
width: Theme.touch-target;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => { root.removed(); }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "×";
|
||||||
|
emphasised: cut.has-hover;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
component StepRow inherits Rectangle {
|
component StepRow inherits Rectangle {
|
||||||
in property <HistoryRow> data;
|
in property <HistoryRow> data;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
@@ -115,6 +214,18 @@ export global Steps {
|
|||||||
callback redo();
|
callback redo();
|
||||||
/// A row's own `index`, not its position in `rows`.
|
/// A row's own `index`, not its position in `rows`.
|
||||||
callback picked(int);
|
callback picked(int);
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-5
|
||||||
|
/// The named snapshots of the open edit, oldest first. Rust owns the
|
||||||
|
/// list; the panel only names, restores, deletes and holds them.
|
||||||
|
in property <[SnapshotRow]> snapshots;
|
||||||
|
/// The name typed for the next one. Empty is allowed: Rust numbers it.
|
||||||
|
callback snapshot-taken(string);
|
||||||
|
callback snapshot-restored(string);
|
||||||
|
callback snapshot-removed(string);
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// `true` on the way down, `false` on the way up.
|
||||||
|
callback snapshot-compared(string, bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
export component HistoryPanel inherits Rectangle {
|
export component HistoryPanel inherits Rectangle {
|
||||||
@@ -189,6 +300,46 @@ export component HistoryPanel inherits Rectangle {
|
|||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-5
|
||||||
|
// Snapshots above the steps: a snapshot is the state worth keeping
|
||||||
|
// out of a run of steps, so it sits where the steps lead to.
|
||||||
|
//
|
||||||
|
// GESTURE: Keep the photograph as it is now, under a name
|
||||||
|
// where: Develop
|
||||||
|
// touch: Type a name in the History panel and press Snapshot
|
||||||
|
// pointer: Type a name in the History panel and press Snapshot
|
||||||
|
// why: The history is forgotten with the sitting, on purpose;
|
||||||
|
// a snapshot is the photographer saying this one should
|
||||||
|
// not be. It is written into the sidecar as a version
|
||||||
|
// of the edit, so it survives a restart and reaches the
|
||||||
|
// other device. Pressing a snapshot puts the photograph
|
||||||
|
// back to it, as one step that undo takes back whole.
|
||||||
|
if Develop.enabled: HorizontalLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
name := Field {
|
||||||
|
label: "Snapshot name";
|
||||||
|
placeholder: "Name this state";
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Snapshot";
|
||||||
|
clicked => {
|
||||||
|
Steps.snapshot-taken(name.text);
|
||||||
|
name.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshot in Steps.snapshots: SnapshotItem {
|
||||||
|
data: snapshot;
|
||||||
|
enabled: Develop.enabled;
|
||||||
|
restored => { Steps.snapshot-restored(snapshot.id); }
|
||||||
|
compared(down) => { Steps.snapshot-compared(snapshot.id, down); }
|
||||||
|
removed => { Steps.snapshot-removed(snapshot.id); }
|
||||||
|
}
|
||||||
|
|
||||||
if Develop.enabled && Steps.rows.length > 0: Rectangle {
|
if Develop.enabled && Steps.rows.length > 0: Rectangle {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export struct PartRow {
|
|||||||
/// The selection the layer began as. It joins nothing and cannot be
|
/// The selection the layer began as. It joins nothing and cannot be
|
||||||
/// removed — removing it is removing the layer.
|
/// removed — removing it is removing the layer.
|
||||||
base: bool,
|
base: bool,
|
||||||
|
/// Left out of the build, and kept. The row's eye.
|
||||||
|
hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which part of a gradient a canvas handle drags.
|
/// Which part of a gradient a canvas handle drags.
|
||||||
@@ -174,6 +176,7 @@ component MaskEntry inherits Rectangle {
|
|||||||
/// Point the tools at one part, change how it joins, or take it out.
|
/// Point the tools at one part, change how it joins, or take it out.
|
||||||
callback part-selected(int);
|
callback part-selected(int);
|
||||||
callback part-join-picked(int, int);
|
callback part-join-picked(int, int);
|
||||||
|
callback part-hidden-toggled(int, bool);
|
||||||
callback part-removed(int);
|
callback part-removed(int);
|
||||||
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
||||||
callback part-added(int);
|
callback part-added(int);
|
||||||
@@ -210,9 +213,11 @@ component MaskEntry inherits Rectangle {
|
|||||||
// where: Develop
|
// where: Develop
|
||||||
// touch: Tap the ring at the head of its row
|
// touch: Tap the ring at the head of its row
|
||||||
// pointer: Click the ring at the head of its row
|
// pointer: Click the ring at the head of its row
|
||||||
|
// keys: H, for the selected layer
|
||||||
// why: Disabling a layer is the before-and-after a local
|
// why: Disabling a layer is the before-and-after a local
|
||||||
// edit constantly wants, so it is one press away rather
|
// edit constantly wants, so it is one press away rather
|
||||||
// than inside the row. It is an edit and does take a
|
// than inside the row. It is an edit and does take a
|
||||||
|
// keys: H, for the selected layer
|
||||||
// history step, unlike holding "Before" — the layer
|
// history step, unlike holding "Before" — the layer
|
||||||
// really is off until it is switched back on.
|
// really is off until it is switched back on.
|
||||||
//
|
//
|
||||||
@@ -386,6 +391,33 @@ component MaskEntry inherits Rectangle {
|
|||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GESTURE: Leave one part out of a mask, and put it back
|
||||||
|
// where: Develop
|
||||||
|
// touch: Tap the ring on the part's row
|
||||||
|
// pointer: Click the ring on the part's row
|
||||||
|
// why: The question a correction raises is whether
|
||||||
|
// it did what it was for — whether the stroke
|
||||||
|
// filled the shoulder, whether the subtracted
|
||||||
|
// gradient took only the sky. Removing it
|
||||||
|
// answers that and loses it. The same ring the
|
||||||
|
// layer wears, one row down, because it is the
|
||||||
|
// same question about a smaller thing.
|
||||||
|
//
|
||||||
|
// TRACES: FR-DEV-19a
|
||||||
|
part-eye := TouchArea {
|
||||||
|
width: Theme.touch-target;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => { root.part-hidden-toggled(i, !part.hidden); }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: part.hidden ? "○" : "◉";
|
||||||
|
emphasised: part-eye.has-hover || !part.hidden;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !part.base: cut := TouchArea {
|
if !part.base: cut := TouchArea {
|
||||||
width: Theme.touch-target;
|
width: Theme.touch-target;
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
@@ -789,6 +821,12 @@ export global Masking {
|
|||||||
callback mask-removed(string);
|
callback mask-removed(string);
|
||||||
callback mask-refined(string);
|
callback mask-refined(string);
|
||||||
callback mask-toggled(string, bool);
|
callback mask-toggled(string, bool);
|
||||||
|
/// TRACES: FR-DEV-16
|
||||||
|
/// The same switch for whichever layers are selected, from the keyboard.
|
||||||
|
/// No id, because the selection is the session's and this file cannot
|
||||||
|
/// see it; Rust answers by flipping each selected layer through the same
|
||||||
|
/// path the ring takes, so it is one history step per layer either way.
|
||||||
|
callback selected-toggled();
|
||||||
callback mask-invert-toggled(string, bool);
|
callback mask-invert-toggled(string, bool);
|
||||||
callback mask-opacity-changed(string, float);
|
callback mask-opacity-changed(string, float);
|
||||||
callback mask-feather-changed(string, float);
|
callback mask-feather-changed(string, float);
|
||||||
@@ -809,6 +847,8 @@ export global Masking {
|
|||||||
/// TRACES: FR-DEV-19a
|
/// TRACES: FR-DEV-19a
|
||||||
callback part-selected(string, int);
|
callback part-selected(string, int);
|
||||||
callback part-join-picked(string, int, int);
|
callback part-join-picked(string, int, int);
|
||||||
|
/// Whether the part at this index is left out of the build.
|
||||||
|
callback part-hidden-toggled(string, int, bool);
|
||||||
callback part-removed(string, int);
|
callback part-removed(string, int);
|
||||||
callback part-added(string, int);
|
callback part-added(string, int);
|
||||||
|
|
||||||
@@ -1148,6 +1188,7 @@ export component MaskPanel inherits Rectangle {
|
|||||||
parts: mask.selected ? Masking.parts : [];
|
parts: mask.selected ? Masking.parts : [];
|
||||||
part-selected(i) => { Masking.part-selected(mask.id, i); }
|
part-selected(i) => { Masking.part-selected(mask.id, i); }
|
||||||
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
|
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
|
||||||
|
part-hidden-toggled(i, on) => { Masking.part-hidden-toggled(mask.id, i, on); }
|
||||||
part-removed(i) => { Masking.part-removed(mask.id, i); }
|
part-removed(i) => { Masking.part-removed(mask.id, i); }
|
||||||
part-added(j) => { Masking.part-added(mask.id, j); }
|
part-added(j) => { Masking.part-added(mask.id, j); }
|
||||||
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
|
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
|
||||||
|
|||||||
@@ -136,6 +136,17 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in property <string> layout-class;
|
in property <string> layout-class;
|
||||||
in property <string> app-version;
|
in property <string> app-version;
|
||||||
|
|
||||||
|
/// TRACES: NFR-OPS-1
|
||||||
|
/// The diagnostics bundle, in its two states. `diagnostics-preview` is
|
||||||
|
/// empty until the user asks for one; while it is not, the panel shows
|
||||||
|
/// what would be written and offers the write. `diagnostics-result` is
|
||||||
|
/// what the last save said — a path, or why it failed.
|
||||||
|
in property <string> diagnostics-preview;
|
||||||
|
in property <string> diagnostics-result;
|
||||||
|
callback diagnostics-prepare();
|
||||||
|
callback diagnostics-save();
|
||||||
|
callback diagnostics-discard();
|
||||||
|
|
||||||
/// TRACES: FR-DSP-8
|
/// TRACES: FR-DSP-8
|
||||||
/// The display showing the canvas, and the colour it is being given.
|
/// The display showing the canvas, and the colour it is being given.
|
||||||
///
|
///
|
||||||
@@ -170,6 +181,13 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in property <[string]> timeline-bar-labels;
|
in property <[string]> timeline-bar-labels;
|
||||||
in property <int> timeline-bars-selected: 0;
|
in property <int> timeline-bars-selected: 0;
|
||||||
callback timeline-bars-picked(int);
|
callback timeline-bars-picked(int);
|
||||||
|
/// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
/// Whether judgements also go to the `.xmp` beside the original, and how
|
||||||
|
/// many sidecars the last scan found disagreeing with the catalog.
|
||||||
|
in property <bool> write-xmp: false;
|
||||||
|
callback write-xmp-toggled(bool);
|
||||||
|
in property <int> xmp-conflicts: 0;
|
||||||
|
callback xmp-reload();
|
||||||
|
|
||||||
// --- export --------------------------------------------------------
|
// --- export --------------------------------------------------------
|
||||||
in property <[string]> format-labels;
|
in property <[string]> format-labels;
|
||||||
@@ -900,6 +918,71 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- diagnostics -----------------------------------------
|
||||||
|
//
|
||||||
|
// TRACES: NFR-OPS-1
|
||||||
|
// Two presses, never one. The first gathers and shows; the
|
||||||
|
// second writes. The requirement asks for a preview-and-
|
||||||
|
// consent step before anything leaves the device, and a
|
||||||
|
// single button that gathered and saved would be the step
|
||||||
|
// skipped under the name of convenience. Nothing is sent by
|
||||||
|
// either press — the file is for the user to attach.
|
||||||
|
Rectangle {
|
||||||
|
width: content.column;
|
||||||
|
height: diagnostics.preferred-height;
|
||||||
|
|
||||||
|
diagnostics := Panel {
|
||||||
|
width: 100%;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
PanelHeading { text: "DIAGNOSTICS"; }
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: "The log, any crash records, and the facts "
|
||||||
|
+ "above, as one text file to attach to a bug "
|
||||||
|
+ "report. Credentials and file paths are "
|
||||||
|
+ "removed first, and you see what is in it "
|
||||||
|
+ "before anything is written.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.diagnostics-preview == "": Rectangle {
|
||||||
|
height: Theme.control-height;
|
||||||
|
|
||||||
|
Button {
|
||||||
|
x: 0;
|
||||||
|
text: "Show what a bundle would contain";
|
||||||
|
clicked => { root.diagnostics-prepare(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.diagnostics-preview != "": Value {
|
||||||
|
text: root.diagnostics-preview;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.diagnostics-preview != "": HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Save the bundle";
|
||||||
|
clicked => { root.diagnostics-save(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Don't";
|
||||||
|
clicked => { root.diagnostics-discard(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.diagnostics-result != "": Caption {
|
||||||
|
text: root.diagnostics-result;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- naming and destination ------------------------------
|
// --- naming and destination ------------------------------
|
||||||
//
|
//
|
||||||
// Its own panel rather than more of the export one: format and
|
// Its own panel rather than more of the export one: format and
|
||||||
@@ -915,6 +998,47 @@ export component SettingsPage inherits Rectangle {
|
|||||||
|
|
||||||
PanelHeading { text: "FILES AND METADATA"; }
|
PanelHeading { text: "FILES AND METADATA"; }
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-13 | NFR-R4
|
||||||
|
// Reading is not a choice — a sidecar another editor
|
||||||
|
// wrote is taken in regardless, since reading changes
|
||||||
|
// nothing in the folder. Writing beside somebody's
|
||||||
|
// originals is, and it starts off.
|
||||||
|
Check {
|
||||||
|
label: "Write ratings, labels and keywords to XMP sidecars";
|
||||||
|
hint: "Beside the originals, as Lightroom and darktable "
|
||||||
|
+ "do, so other applications see them. Only those "
|
||||||
|
+ "fields are written; everything else in an "
|
||||||
|
+ "existing sidecar is left exactly as it was.";
|
||||||
|
checked: root.write-xmp;
|
||||||
|
toggled(on) => { root.write-xmp-toggled(on); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offered reload. Shown only while there is
|
||||||
|
// something to offer: a disagreement between an
|
||||||
|
// `.xmp` changed elsewhere and what this catalog
|
||||||
|
// holds, which the automatic pull records rather
|
||||||
|
// than resolves.
|
||||||
|
if root.xmp-conflicts > 0: Caption {
|
||||||
|
text: root.xmp-conflicts
|
||||||
|
+ (root.xmp-conflicts == 1
|
||||||
|
? " XMP sidecar disagrees"
|
||||||
|
: " XMP sidecars disagree")
|
||||||
|
+ " with this catalog about a rating, label or "
|
||||||
|
+ "caption. The catalog's values stand until you "
|
||||||
|
+ "say otherwise.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.xmp-conflicts > 0: Rectangle {
|
||||||
|
height: Theme.control-height;
|
||||||
|
|
||||||
|
Button {
|
||||||
|
x: 0;
|
||||||
|
text: "Take the sidecars' values";
|
||||||
|
clicked => { root.xmp-reload(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Filename template";
|
label: "Filename template";
|
||||||
hint: "{name} {seq} {date} {dimensions} {preset}";
|
hint: "{name} {seq} {date} {dimensions} {preset}";
|
||||||
|
|||||||
Reference in New Issue
Block a user