Compare commits
70
Commits
607a4e0df4
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 | ||
|
|
1b91b0a94e | ||
|
|
890e7281eb | ||
|
|
ccaad2d940 | ||
|
|
7e5e20cb69 | ||
|
|
e72039c123 | ||
|
|
fa02e774ca | ||
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
ad7b91cf91 | ||
|
|
8b931e3625 | ||
|
|
73b18dec5b | ||
|
|
3bc0d14a34 | ||
|
|
4aa2815c6e | ||
|
|
8596f15d52 | ||
|
|
407136c5a2 | ||
|
|
83b5f6bf75 | ||
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
f5c57649e1 | ||
|
|
e967502ea7 | ||
|
|
eb3ae08cf9 | ||
|
|
3d8e70be62 | ||
|
|
f43c275152 | ||
|
|
48d2816c73 | ||
|
|
b494b4fffd | ||
|
|
c39afe0dfd | ||
|
|
1d78957d7a | ||
|
|
99c0d7e83d | ||
|
|
398d9b4756 | ||
|
|
538ef1e05f | ||
|
|
d40932f4cf | ||
|
|
1931da8448 | ||
|
|
f896f60d1a | ||
|
|
2f305c481e | ||
|
|
4249067345 | ||
|
|
800c980137 | ||
|
|
67fb3a9b37 | ||
|
|
eb1503aba9 | ||
|
|
db47a975f0 | ||
|
|
990c49a436 | ||
|
|
60972a0704 | ||
|
|
aa32cc45d3 | ||
|
|
17c47af9f1 | ||
|
|
5bea928042 | ||
|
|
5c8973de73 | ||
|
|
29ecc79585 | ||
|
|
7c5a5e7798 | ||
|
|
d37dcedfd9 |
@@ -30,23 +30,33 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "$line"
|
echo "$line"
|
||||||
|
|
||||||
|
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||||
|
# test legs: they exist to package a build already validated elsewhere.
|
||||||
|
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
# installed on macOS 12 (#109). The script installs a pinned
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
# prebuilt release instead.
|
# prebuilt release instead. dmgbuild is not installed here — the
|
||||||
|
# test job does not package (#95).
|
||||||
- name: Ensure host tools
|
- name: Ensure host tools
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: scripts/ensure-host-tools.sh
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
- name: Build for testing (universal)
|
# Tests only ever run on the runner's own architecture; build
|
||||||
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
|
# produces the universal Release binary.
|
||||||
|
- name: Build for testing (host arch)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED" \
|
-derivedDataPath "$DERIVED" \
|
||||||
-configuration Debug \
|
-configuration Debug \
|
||||||
ARCHS='arm64 x86_64' \
|
ARCHS="$(uname -m)" \
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
CODE_SIGN_IDENTITY='-'
|
||||||
@@ -57,6 +67,7 @@ jobs:
|
|||||||
# launch (run 31992) — so sign every embedded copy once the build is
|
# launch (run 31992) — so sign every embedded copy once the build is
|
||||||
# done (embed steps run after any build script phase) (#119).
|
# done (embed steps run after any build script phase) (#119).
|
||||||
- name: Sign package product frameworks
|
- name: Sign package product frameworks
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||||
| while IFS= read -r -d '' fw; do
|
| while IFS= read -r -d '' fw; do
|
||||||
@@ -65,6 +76,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Test unit (ICCeryCoreTests)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||||
@@ -87,6 +99,7 @@ jobs:
|
|||||||
# tag packaging can proceed. Real XCTest assertion failures
|
# tag packaging can proceed. Real XCTest assertion failures
|
||||||
# still fail the job.
|
# still fail the job.
|
||||||
- name: Test UI (ICCeryUITests)
|
- name: Test UI (ICCeryUITests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
@@ -130,7 +143,8 @@ jobs:
|
|||||||
echo "error: UI probe failed with a real test error" >&2
|
echo "error: UI probe failed with a real test error" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||||
|
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if is_runner_attach_failure; then
|
if is_runner_attach_failure; then
|
||||||
@@ -150,7 +164,7 @@ jobs:
|
|||||||
# xcresult on failure — upload it so UI failures can be triaged
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
# without access to the runner (#126).
|
# without access to the runner (#126).
|
||||||
- name: Prepare Node CA bundle (failure path)
|
- name: Prepare Node CA bundle (failure path)
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: |
|
run: |
|
||||||
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
security find-certificate -a -p \
|
security find-certificate -a -p \
|
||||||
@@ -162,7 +176,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload UI test xcresult
|
- name: Upload UI test xcresult
|
||||||
if: failure()
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
env:
|
env:
|
||||||
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
@@ -180,8 +194,10 @@ jobs:
|
|||||||
|
|
||||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||||
|
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||||
|
# the test job never pip-installs it (#95).
|
||||||
- name: Ensure host tools
|
- name: Ensure host tools
|
||||||
run: scripts/ensure-host-tools.sh
|
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Package release
|
- name: Package release
|
||||||
run: scripts/package-release.sh
|
run: scripts/package-release.sh
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# GitHub Actions twin of .gitea/workflows/macos.yml.
|
||||||
|
# Deltas from the Gitea file (everything else is the same jobs/steps):
|
||||||
|
# - runs-on macos-14: github.com retired macos-12. Do not use
|
||||||
|
# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800
|
||||||
|
# window hangs off the virtual display and XCTest marks sidebar
|
||||||
|
# controls (x ≈ -116) as not hittable (run 34864198118).
|
||||||
|
# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner
|
||||||
|
# still uses v3.
|
||||||
|
# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the
|
||||||
|
# private Gitea CA when the runner talks to git.i3omb.com.
|
||||||
|
# - Tag DMGs go to a GitHub Release via `gh` instead of
|
||||||
|
# scripts/attach-release-asset.sh (Gitea /api/v1).
|
||||||
|
name: macOS CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: macos-14
|
||||||
|
env:
|
||||||
|
DERIVED: build/DerivedData-test
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Assert Xcode 14+ toolchain
|
||||||
|
run: |
|
||||||
|
line="$(xcodebuild -version | head -1)"
|
||||||
|
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
|
||||||
|
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
|
||||||
|
echo "Unexpected Xcode version: $line" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "$line"
|
||||||
|
|
||||||
|
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||||
|
# test legs: they exist to package a build already validated elsewhere.
|
||||||
|
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||||
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
|
# prebuilt release instead. dmgbuild is not installed here — the
|
||||||
|
# test job does not package (#95).
|
||||||
|
- name: Ensure host tools
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
|
- name: Generate Xcode project
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
|
# Tests only ever run on the runner's own architecture; build
|
||||||
|
# just that slice. Packaging (scripts/package-release.sh) still
|
||||||
|
# produces the universal Release binary.
|
||||||
|
- name: Build for testing (host arch)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: |
|
||||||
|
xcodebuild build-for-testing \
|
||||||
|
-scheme ICCery \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
-derivedDataPath "$DERIVED" \
|
||||||
|
-configuration Debug \
|
||||||
|
ARCHS="$(uname -m)" \
|
||||||
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
|
CODE_SIGN_IDENTITY='-'
|
||||||
|
|
||||||
|
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||||
|
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||||
|
# every loaded dylib to carry a cdhash — dyld killed the test host at
|
||||||
|
# launch (run 31992) — so sign every embedded copy once the build is
|
||||||
|
# done (embed steps run after any build script phase) (#119).
|
||||||
|
- name: Sign package product frameworks
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: |
|
||||||
|
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||||
|
| while IFS= read -r -d '' fw; do
|
||||||
|
echo "signing $fw"
|
||||||
|
codesign --force --sign - --timestamp=none "$fw"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Test unit (ICCeryCoreTests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: |
|
||||||
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
|
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||||
|
echo "error: no xctestrun produced by build-for-testing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "xctestrun: $XCTESTRUN"
|
||||||
|
xcodebuild test-without-building \
|
||||||
|
-xctestrun "$XCTESTRUN" \
|
||||||
|
-only-testing:ICCeryCoreTests \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
-derivedDataPath "$DERIVED"
|
||||||
|
|
||||||
|
# UI tests need macOS Automation / Accessibility permission on the
|
||||||
|
# runner. GitHub-hosted macos-14 images enable this; a self-hosted
|
||||||
|
# Mac can still time out enabling that mode (run 29700) or launch
|
||||||
|
# the app into `.runningBackground` (run 29804). Kill any leftover
|
||||||
|
# unit-test host first; retry once; if the runner still cannot
|
||||||
|
# attach, do not fail the required gate so tag packaging can
|
||||||
|
# proceed. Real XCTest assertion failures still fail the job.
|
||||||
|
- name: Test UI (ICCeryUITests)
|
||||||
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
|
LOG="$DERIVED/ui-test.log"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
run_ui() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
echo "::group::UI tests $label"
|
||||||
|
set +e
|
||||||
|
xcodebuild test-without-building \
|
||||||
|
-xctestrun "$XCTESTRUN" \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
-derivedDataPath "$DERIVED" \
|
||||||
|
"$@" | tee "$LOG"
|
||||||
|
rc=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
echo "::endgroup::"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_runner_attach_failure() {
|
||||||
|
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while [ "$attempt" -le 2 ]; do
|
||||||
|
# Probe one case first. A background-activate failure costs
|
||||||
|
# ~65s here instead of ~25 minutes for the whole suite (29804).
|
||||||
|
if ! run_ui "probe attempt $attempt" \
|
||||||
|
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
|
if is_runner_attach_failure; then
|
||||||
|
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 8
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "error: UI probe failed with a real test error" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||||
|
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if is_runner_attach_failure; then
|
||||||
|
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||||
|
pkill -x ICCery 2>/dev/null || true
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 8
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "error: UI tests failed with a real test error" >&2
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||||
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
|
# without access to the runner (#126).
|
||||||
|
- name: Upload UI test xcresult
|
||||||
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ui-test-xcresult
|
||||||
|
path: build/DerivedData-test/Logs/Test
|
||||||
|
|
||||||
|
package:
|
||||||
|
needs: build-and-test
|
||||||
|
runs-on: macos-14
|
||||||
|
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||||
|
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||||
|
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||||
|
# the test job never pip-installs it (#95).
|
||||||
|
- name: Ensure host tools
|
||||||
|
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
|
- name: Package release
|
||||||
|
run: scripts/package-release.sh
|
||||||
|
env:
|
||||||
|
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||||
|
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||||
|
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
|
||||||
|
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
|
||||||
|
- name: Upload DMG artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: iccery-dmg
|
||||||
|
path: ICCery-*.dmg
|
||||||
|
|
||||||
|
- name: Attach DMG to GitHub release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
case "$TAG" in
|
||||||
|
*prerelease*) PRE_FLAG=--prerelease ;;
|
||||||
|
*) PRE_FLAG= ;;
|
||||||
|
esac
|
||||||
|
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG
|
||||||
|
fi
|
||||||
|
gh release upload "$TAG" ICCery-*.dmg --clobber
|
||||||
@@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
|
|||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
codesign -dvv <sidecar>
|
codesign -dvv <sidecar>
|
||||||
```
|
```
|
||||||
|
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
# BUILD-PLAN.md — ICCery v2 Mac
|
# BUILD-PLAN.md — ICCery v2 Mac
|
||||||
|
|
||||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
||||||
|
|
||||||
## Sprint rule
|
## Sprint rule
|
||||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||||
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||||
|
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
|
||||||
|
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
|
||||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
Issue **16 is not an M3 or M6 exit gate.**
|
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||||
|
|
||||||
|
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
|
|||||||
|
|
||||||
// MARK: - Shared collection
|
// MARK: - Shared collection
|
||||||
|
|
||||||
|
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
|
||||||
|
/// intervals shrink ~10x — same env convention as `AppPaths.testRoot`.
|
||||||
|
/// Release builds compile the branch out entirely; no static state.
|
||||||
|
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
|
||||||
|
#if DEBUG
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||||
|
return nanos / 10
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return nanos
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancels any previous child with the same id and waits for it to
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
/// `duplicateID` from a leftover process (#50, #52).
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +742,148 @@ public struct ArgyllRunner: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - spotread (spot-read console, issue #148)
|
||||||
|
|
||||||
|
/// Runs `spotread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Same subscribe-before-spawn shape as `runChartread`, but there is
|
||||||
|
/// no artefact: the stream ends with `.exit(code)`. The single-lease
|
||||||
|
/// process id is `ProcessID.spotread` — never `chartread_{basename}`.
|
||||||
|
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
|
||||||
|
/// `chartread` fallback (#116, R14/R21).
|
||||||
|
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = SpotReadArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("spotread")
|
||||||
|
let processId = ProcessID.spotread
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
let instrumentName = config.instrumentName
|
||||||
|
let instrumentPort = config.instrumentPort
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// XY parking hook before any kill, same as chartread.
|
||||||
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
|
if isXY {
|
||||||
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard binaryResolver.exists(binaryURL) else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1,
|
||||||
|
logs: ["spotread sidecar missing — run fetch-argyll"])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "spotread", code: -1, logs: [error.localizedDescription])))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ChartreadState = .idle
|
||||||
|
var pendingLogs: [String] = []
|
||||||
|
var lastFlush = Date()
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
func flushLogs() {
|
||||||
|
guard !pendingLogs.isEmpty else { return }
|
||||||
|
let batch = pendingLogs
|
||||||
|
pendingLogs.removeAll(keepingCapacity: true)
|
||||||
|
continuation.yield(.log(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
if let parsed = SpotReadParser.parse(line: line) {
|
||||||
|
continuation.yield(.sample(SpotReadSample(
|
||||||
|
lab: parsed.lab,
|
||||||
|
xyz: parsed.xyz,
|
||||||
|
instrumentName: instrumentName,
|
||||||
|
port: instrumentPort,
|
||||||
|
rawLine: line
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
let classified = SpotReadClassifier.classify(
|
||||||
|
line: line, previousState: state)
|
||||||
|
if classified.state != state
|
||||||
|
|| classified.requestedWarningKey != nil {
|
||||||
|
state = classified.state
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow:
|
||||||
|
// spotread is never run with `-u`.
|
||||||
|
break
|
||||||
|
|
||||||
|
case .error(_, let message):
|
||||||
|
pendingLogs.append("Error: \(message)")
|
||||||
|
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flushLogs()
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != nil {
|
||||||
|
flushLogs()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(.exit(exitCode ?? -1))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send input bytes to the running `spotread` child. Reuses
|
||||||
|
/// `ChartreadInput` — the stdin protocol is identical.
|
||||||
|
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
|
||||||
|
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `spotread` child. The XY park (`q\n` +
|
||||||
|
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
|
||||||
|
public func cancelSpotread() {
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Stage 0 calibration
|
// MARK: - Stage 0 calibration
|
||||||
|
|
||||||
/// Generates a calibration wedge `.ti1`.
|
/// Generates a calibration wedge `.ti1`.
|
||||||
@@ -816,6 +970,20 @@ public enum ChartreadEvent: Sendable {
|
|||||||
case failed(ArgyllRunnerError)
|
case failed(ArgyllRunnerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Events emitted by a running `spotread` session (issue #148).
|
||||||
|
public enum SpotReadEvent: Sendable {
|
||||||
|
/// Classified prompt / state update (reuses `ChartreadState`).
|
||||||
|
case prompt(ChartreadClassifyResult)
|
||||||
|
/// A parsed `Result is …` sample line.
|
||||||
|
case sample(SpotReadSample)
|
||||||
|
/// A batched log chunk (stdout + stderr lines).
|
||||||
|
case log([String])
|
||||||
|
/// Process exited with the given code.
|
||||||
|
case exit(Int32)
|
||||||
|
/// Failure (missing sidecar, spawn error).
|
||||||
|
case failed(ArgyllRunnerError)
|
||||||
|
}
|
||||||
|
|
||||||
/// Exact bytes sent to `chartread` stdin.
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
public enum ChartreadInput: Sendable {
|
public enum ChartreadInput: Sendable {
|
||||||
case trigger // " \n"
|
case trigger // " \n"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
|
||||||
|
public enum SpotReadArgs {
|
||||||
|
|
||||||
|
/// Builds `spotread` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// - Always `-v -e` (paper / reflective; never display `-d`).
|
||||||
|
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
|
||||||
|
/// (Auto and port 1 omit it, #111).
|
||||||
|
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
|
||||||
|
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
|
||||||
|
/// profcheck only.
|
||||||
|
/// - No basename — `spotread` writes no artefact.
|
||||||
|
public static func build(config: SpotReadConfig) -> [String] {
|
||||||
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
|
if let port = config.selectedPort, port > 1 {
|
||||||
|
args.append(contentsOf: ["-c", "\(port)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enableLEDs {
|
||||||
|
args.append(contentsOf: ["-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a `spotread` invocation (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` writes no artefact; `workingDirectory` is still required
|
||||||
|
/// for the spawn (#59 — empty cwd is illegal).
|
||||||
|
public struct SpotReadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port for `spotread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
/// Whether the selected instrument is an XY table — controls the
|
||||||
|
/// `q\n` + ~500 ms park before kill on cancel.
|
||||||
|
public var isXY: Bool
|
||||||
|
/// Display name stamped onto each `SpotReadSample`.
|
||||||
|
public var instrumentName: String
|
||||||
|
/// Instrument port stamped onto each sample (nil for Auto).
|
||||||
|
public var instrumentPort: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false,
|
||||||
|
instrumentName: String = "",
|
||||||
|
instrumentPort: Int? = nil
|
||||||
|
) {
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
self.instrumentName = instrumentName
|
||||||
|
self.instrumentPort = instrumentPort
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel
|
||||||
|
/// (issue #147).
|
||||||
|
///
|
||||||
|
/// This is a fixed-matrix helper, **not** a colour-management module: it
|
||||||
|
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
|
||||||
|
/// output "approx. Lab, not ColorSync".
|
||||||
|
public enum ApproximateLab {
|
||||||
|
|
||||||
|
/// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1.
|
||||||
|
private static let srgbToXYZ: [[Double]] = [
|
||||||
|
[0.4124, 0.3576, 0.1805],
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
[0.0193, 0.1192, 0.9505],
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 8-bit sRGB triple → Lab D50 (approximate).
|
||||||
|
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
|
||||||
|
srgbToLab(DisplayRGB(
|
||||||
|
r: Double(r) / 255.0,
|
||||||
|
g: Double(g) / 255.0,
|
||||||
|
b: Double(b) / 255.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 0–1 sRGB triple → Lab D50 (approximate).
|
||||||
|
public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor {
|
||||||
|
func linear(_ c: Double) -> Double {
|
||||||
|
c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)]
|
||||||
|
// 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in.
|
||||||
|
let xyz65 = XYZColor(
|
||||||
|
x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100,
|
||||||
|
y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100,
|
||||||
|
z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100)
|
||||||
|
return LabColorMath.xyzToLab(adaptD65ToD50(xyz65))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bradford D65 → D50 chromatic adaptation — the mirror of
|
||||||
|
/// `LabColorMath.adaptD50ToD65`.
|
||||||
|
private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor {
|
||||||
|
let m = LabColorMath.bradford
|
||||||
|
let inv = LabColorMath.bradfordInv
|
||||||
|
let d65 = LabColorMath.d65White
|
||||||
|
let d50 = LabColorMath.d50White
|
||||||
|
let source = multiply(m, [xyz.x, xyz.y, xyz.z])
|
||||||
|
let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z])
|
||||||
|
let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z])
|
||||||
|
let scaled = [
|
||||||
|
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||||
|
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||||
|
source[2] * (dstWhite[2] / srcWhite[2]),
|
||||||
|
]
|
||||||
|
return XYZColor(
|
||||||
|
x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2],
|
||||||
|
y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2],
|
||||||
|
z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||||
|
m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// Result of a point-in-gamut test (issue #147).
|
||||||
|
public enum GamutContainment: String, Sendable, Equatable {
|
||||||
|
/// The point lies inside the mesh volume.
|
||||||
|
case inside
|
||||||
|
/// The point lies outside the mesh volume.
|
||||||
|
case outside
|
||||||
|
/// The mesh has no faces to test against.
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point-in-mesh containment and volume estimation for ``GamutMesh``.
|
||||||
|
///
|
||||||
|
/// Both tests run on the scene-space positions stored on
|
||||||
|
/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same
|
||||||
|
/// mapping the SceneKit viewer uses. No colour management is involved.
|
||||||
|
public enum GamutGeometry {
|
||||||
|
|
||||||
|
/// Whether `lab` is inside `mesh`.
|
||||||
|
///
|
||||||
|
/// Ray-casts through the face list: an odd crossing count means the
|
||||||
|
/// point is inside a closed surface. A ray that grazes a vertex or
|
||||||
|
/// edge gives an ambiguous count, so the test retries with off-axis
|
||||||
|
/// directions before answering. Meshes without faces report
|
||||||
|
/// ``GamutContainment/unknown``.
|
||||||
|
public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment {
|
||||||
|
guard !mesh.faces.isEmpty else { return .unknown }
|
||||||
|
let origin = SIMD3<Double>(lab.a, lab.l, lab.b)
|
||||||
|
for direction in rayDirections {
|
||||||
|
if let inside = castRay(from: origin, direction: direction, mesh: mesh) {
|
||||||
|
return inside ? .inside : .outside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate mesh volume in Lab-cubic units.
|
||||||
|
///
|
||||||
|
/// Sums signed tetrahedra from the vertex centroid to each face; for
|
||||||
|
/// a closed surface the magnitude equals the enclosed volume
|
||||||
|
/// regardless of face winding. Returns 0 for empty or face-less
|
||||||
|
/// meshes.
|
||||||
|
public static func volume(of mesh: GamutMesh) -> Double {
|
||||||
|
guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 }
|
||||||
|
var centroid = SIMD3<Double>.zero
|
||||||
|
for vertex in mesh.vertices {
|
||||||
|
centroid += SIMD3<Double>(vertex.position)
|
||||||
|
}
|
||||||
|
centroid /= Double(mesh.vertices.count)
|
||||||
|
|
||||||
|
var sum = 0.0
|
||||||
|
let count = mesh.vertices.count
|
||||||
|
for face in mesh.faces {
|
||||||
|
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let a = SIMD3<Double>(mesh.vertices[Int(face.a)].position) - centroid
|
||||||
|
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
|
||||||
|
let c = SIMD3<Double>(mesh.vertices[Int(face.c)].position) - centroid
|
||||||
|
sum += simd_dot(a, simd_cross(b, c)) / 6.0
|
||||||
|
}
|
||||||
|
return abs(sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ray casting
|
||||||
|
|
||||||
|
/// Primary +X ray, then off-axis retries for degenerate edge hits.
|
||||||
|
private static let rayDirections: [SIMD3<Double>] = [
|
||||||
|
SIMD3(1, 0, 0),
|
||||||
|
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
|
||||||
|
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Möller–Trumbore crossing count. Returns `nil` when a crossing
|
||||||
|
/// lands on a triangle edge or vertex (ambiguous parity) so the
|
||||||
|
/// caller can retry with a different direction.
|
||||||
|
private static func castRay(
|
||||||
|
from origin: SIMD3<Double>,
|
||||||
|
direction dir: SIMD3<Double>,
|
||||||
|
mesh: GamutMesh
|
||||||
|
) -> Bool? {
|
||||||
|
let epsilon = 1e-9
|
||||||
|
var crossings = 0
|
||||||
|
let count = mesh.vertices.count
|
||||||
|
for face in mesh.faces {
|
||||||
|
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let va = SIMD3<Double>(mesh.vertices[Int(face.a)].position)
|
||||||
|
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
|
||||||
|
let vc = SIMD3<Double>(mesh.vertices[Int(face.c)].position)
|
||||||
|
|
||||||
|
let e1 = vb - va
|
||||||
|
let e2 = vc - va
|
||||||
|
let p = simd_cross(dir, e2)
|
||||||
|
let det = simd_dot(e1, p)
|
||||||
|
if abs(det) < 1e-12 { continue } // ray parallel to face
|
||||||
|
let inv = 1.0 / det
|
||||||
|
let tvec = origin - va
|
||||||
|
let u = simd_dot(tvec, p) * inv
|
||||||
|
let q = simd_cross(tvec, e1)
|
||||||
|
let v = simd_dot(dir, q) * inv
|
||||||
|
let t = simd_dot(e2, q) * inv
|
||||||
|
|
||||||
|
guard t > epsilon else { continue }
|
||||||
|
if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue }
|
||||||
|
// Crossing on an edge or vertex — parity is ambiguous.
|
||||||
|
if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil }
|
||||||
|
crossings += 1
|
||||||
|
}
|
||||||
|
return crossings % 2 == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A parsed gamut mesh plus the display metadata the compare viewer
|
||||||
|
/// needs (issue #147).
|
||||||
|
///
|
||||||
|
/// `displayName` is user-derived (a file name); views must render it
|
||||||
|
/// through `Text` only (#114).
|
||||||
|
public struct NamedGamut: Sendable, Equatable, Identifiable {
|
||||||
|
|
||||||
|
/// What the layer is used for in the compare UI.
|
||||||
|
public enum Role: String, Sendable, Equatable {
|
||||||
|
/// Bundled reference space (sRGB). Cannot be removed, only hidden.
|
||||||
|
case reference
|
||||||
|
/// The workflow's own profile gamut.
|
||||||
|
case profileA
|
||||||
|
/// The user-added compare gamut. Replaced, never stacked.
|
||||||
|
case profileB
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String
|
||||||
|
public var displayName: String
|
||||||
|
public var role: Role
|
||||||
|
public var mesh: GamutMesh
|
||||||
|
public var sourceURL: URL
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
displayName: String,
|
||||||
|
role: Role,
|
||||||
|
mesh: GamutMesh,
|
||||||
|
sourceURL: URL
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.displayName = displayName
|
||||||
|
self.role = role
|
||||||
|
self.mesh = mesh
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The `.icceryproj` file — a JSON **index** over a basename + working
|
||||||
|
/// directory + optional media recipe/preset binding + last verification
|
||||||
|
/// snapshot (issue #149, docs/06 §Project file).
|
||||||
|
///
|
||||||
|
/// The project is never a second source of truth: artefact gating stays
|
||||||
|
/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to
|
||||||
|
/// persist the live session. snake_case keys match the v1 schema;
|
||||||
|
/// `schema_version != 1` is a hard decode error — v2 fields are never
|
||||||
|
/// partially decoded.
|
||||||
|
public struct ICCeryProject: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The only schema version this build reads and writes.
|
||||||
|
public static let currentSchemaVersion = 1
|
||||||
|
|
||||||
|
public var schemaVersion: Int
|
||||||
|
public var name: String
|
||||||
|
public var notes: String
|
||||||
|
/// Run name without extension — never `CAL_`-persisted (#60/R11).
|
||||||
|
public var basename: String
|
||||||
|
/// Absolute working directory holding the artefacts (#59).
|
||||||
|
public var cwd: String
|
||||||
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
|
public var profileBasename: String?
|
||||||
|
/// CUPS queue id, when a printer was selected.
|
||||||
|
public var printerID: String?
|
||||||
|
public var printerDisplayName: String?
|
||||||
|
/// `MediaRecipe.id` — optional; ignored when the library file is
|
||||||
|
/// absent or the id is unknown (soft-dependency, #146).
|
||||||
|
public var mediaRecipeID: String?
|
||||||
|
public var presetID: String?
|
||||||
|
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||||
|
public var calibrationURL: String?
|
||||||
|
public var lastVerification: VerificationSnapshot?
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
schemaVersion: Int = ICCeryProject.currentSchemaVersion,
|
||||||
|
name: String = "",
|
||||||
|
notes: String = "",
|
||||||
|
basename: String,
|
||||||
|
cwd: String,
|
||||||
|
profileBasename: String? = nil,
|
||||||
|
printerID: String? = nil,
|
||||||
|
printerDisplayName: String? = nil,
|
||||||
|
mediaRecipeID: String? = nil,
|
||||||
|
presetID: String? = nil,
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
lastVerification: VerificationSnapshot? = nil,
|
||||||
|
updated: Date = Date()
|
||||||
|
) {
|
||||||
|
self.schemaVersion = schemaVersion
|
||||||
|
self.name = name
|
||||||
|
self.notes = notes
|
||||||
|
self.basename = basename
|
||||||
|
self.cwd = cwd
|
||||||
|
self.profileBasename = profileBasename
|
||||||
|
self.printerID = printerID
|
||||||
|
self.printerDisplayName = printerDisplayName
|
||||||
|
self.mediaRecipeID = mediaRecipeID
|
||||||
|
self.presetID = presetID
|
||||||
|
self.calibrationURL = calibrationURL
|
||||||
|
self.lastVerification = lastVerification
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion = "schema_version"
|
||||||
|
case name, notes, basename, cwd
|
||||||
|
case profileBasename = "profile_basename"
|
||||||
|
case printerID = "printer_id"
|
||||||
|
case printerDisplayName = "printer_display_name"
|
||||||
|
case mediaRecipeID = "media_recipe_id"
|
||||||
|
case presetID = "preset_id"
|
||||||
|
case calibrationURL = "calibration_url"
|
||||||
|
case lastVerification = "last_verification"
|
||||||
|
case updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: `schema_version` is required and must equal 1 —
|
||||||
|
/// anything else throws before a single v2 field is read. Required
|
||||||
|
/// strings (`basename`, `cwd`) must be present; optionals default.
|
||||||
|
/// Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let version = try c.decode(Int.self, forKey: .schemaVersion)
|
||||||
|
guard version == ICCeryProject.currentSchemaVersion else {
|
||||||
|
throw ValidationError.unsupportedSchema(version)
|
||||||
|
}
|
||||||
|
schemaVersion = version
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
basename = try c.decode(String.self, forKey: .basename)
|
||||||
|
cwd = try c.decode(String.self, forKey: .cwd)
|
||||||
|
profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename)
|
||||||
|
printerID = try c.decodeIfPresent(String.self, forKey: .printerID)
|
||||||
|
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName)
|
||||||
|
mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID)
|
||||||
|
presetID = try c.decodeIfPresent(String.self, forKey: .presetID)
|
||||||
|
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||||
|
lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification)
|
||||||
|
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case unsupportedSchema(Int)
|
||||||
|
case emptyBasename
|
||||||
|
case invalidBasename(String)
|
||||||
|
case emptyCwd
|
||||||
|
case unsafeCwd(String)
|
||||||
|
case invalidCalibrationURL(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsupportedSchema:
|
||||||
|
return "This project file is not schema 1."
|
||||||
|
case .emptyBasename:
|
||||||
|
return "A project needs a target basename."
|
||||||
|
case .invalidBasename(let v):
|
||||||
|
return "Illegal project basename \"\(v)\"."
|
||||||
|
case .emptyCwd:
|
||||||
|
return "A project needs a working folder."
|
||||||
|
case .unsafeCwd(let v):
|
||||||
|
return "cwd must be an absolute path, got \"\(v)\"."
|
||||||
|
case .invalidCalibrationURL(let v):
|
||||||
|
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the index fields. Empty basename/cwd refuse (#59/#60);
|
||||||
|
/// basename still rejects `/`, `\`, `..`; cwd must be absolute. An
|
||||||
|
/// empty `name` falls back to the basename.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> ICCeryProject {
|
||||||
|
var p = self
|
||||||
|
p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !p.basename.isEmpty else { throw ValidationError.emptyBasename }
|
||||||
|
guard PathSecurity.isValidBasename(p.basename) else {
|
||||||
|
throw ValidationError.invalidBasename(p.basename)
|
||||||
|
}
|
||||||
|
let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd }
|
||||||
|
guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else {
|
||||||
|
throw ValidationError.unsafeCwd(p.cwd)
|
||||||
|
}
|
||||||
|
p.cwd = trimmedCwd
|
||||||
|
if p.name.isEmpty { p.name = p.basename }
|
||||||
|
if let cal = p.calibrationURL, !cal.isEmpty {
|
||||||
|
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||||
|
throw ValidationError.invalidCalibrationURL(cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.icceryproj` file. Throws `unsupportedSchema` for
|
||||||
|
/// `schema_version != 1` and the decode/validation error otherwise;
|
||||||
|
/// callers must leave live state untouched on failure.
|
||||||
|
public static func load(from url: URL) throws -> ICCeryProject {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return try decoder.decode(ICCeryProject.self, from: data).validated()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213).
|
||||||
|
/// Validation runs first — a refused project never touches disk.
|
||||||
|
public func save(to url: URL) throws {
|
||||||
|
let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601)
|
||||||
|
try AtomicFileWriter.write(try encoder.encode(validated()), to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last `VerificationRecord` frozen into the project file — notes
|
||||||
|
/// only, never gating truth.
|
||||||
|
public struct VerificationSnapshot: Codable, Equatable, Sendable {
|
||||||
|
public var date: Date
|
||||||
|
public var avgDE00: Double
|
||||||
|
public var maxDE00: Double
|
||||||
|
/// `VerificationStatus.rawValue`.
|
||||||
|
public var status: String
|
||||||
|
public var profileFilename: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
date: Date,
|
||||||
|
avgDE00: Double,
|
||||||
|
maxDE00: Double,
|
||||||
|
status: String,
|
||||||
|
profileFilename: String
|
||||||
|
) {
|
||||||
|
self.date = date
|
||||||
|
self.avgDE00 = avgDE00
|
||||||
|
self.maxDE00 = maxDE00
|
||||||
|
self.status = status
|
||||||
|
self.profileFilename = profileFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(record: VerificationRecord) {
|
||||||
|
self.init(
|
||||||
|
date: record.timestamp,
|
||||||
|
avgDE00: record.avgDE,
|
||||||
|
maxDE00: record.maxDE,
|
||||||
|
status: record.status.rawValue,
|
||||||
|
profileFilename: record.profileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case date
|
||||||
|
case avgDE00 = "avg_de00"
|
||||||
|
case maxDE00 = "max_de00"
|
||||||
|
case status
|
||||||
|
case profileFilename = "profile_filename"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `MediaRecipe` entries (issue #146).
|
||||||
|
///
|
||||||
|
/// `media_library.json` is a sibling of `settings.json`, never a field
|
||||||
|
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
|
||||||
|
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
|
||||||
|
/// never overwritten — the view model turns the throw into an empty
|
||||||
|
/// list plus a persistent warning banner.
|
||||||
|
public actor MediaLibraryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 200
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var recipes: [MediaRecipe] = []
|
||||||
|
|
||||||
|
/// Explicit load flag — an empty file is still "loaded".
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[MediaRecipe]>
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads recipes from disk. Returns the existing cache if already
|
||||||
|
/// loaded.
|
||||||
|
///
|
||||||
|
/// Throws when the file exists but cannot be parsed; the existing
|
||||||
|
/// file is never overwritten in that case and `loaded` stays false
|
||||||
|
/// so the next call re-reads.
|
||||||
|
public func load() throws -> [MediaRecipe] {
|
||||||
|
guard !loaded else { return recipes }
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
loaded = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
recipes = try fileStore.load()
|
||||||
|
loaded = true
|
||||||
|
return recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all cached recipes.
|
||||||
|
public func all() -> [MediaRecipe] {
|
||||||
|
recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or replaces a recipe matched by `id`, then writes
|
||||||
|
/// atomically. Replacement preserves `created` and bumps `updated`;
|
||||||
|
/// inserts beyond `capacity` throw `.capacityReached` — no silent
|
||||||
|
/// eviction.
|
||||||
|
///
|
||||||
|
/// Loads the existing library first and propagates any load error
|
||||||
|
/// so an unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
|
||||||
|
let validated = try recipe.validated()
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = recipes
|
||||||
|
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
|
||||||
|
var existing = validated
|
||||||
|
existing.created = updated[index].created
|
||||||
|
existing.updated = Date()
|
||||||
|
updated[index] = existing
|
||||||
|
} else {
|
||||||
|
guard updated.count < capacity else {
|
||||||
|
throw MediaLibraryError.capacityReached(capacity)
|
||||||
|
}
|
||||||
|
updated.append(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a recipe by id and writes atomically. Returns false when
|
||||||
|
/// no recipe with that id exists.
|
||||||
|
@discardableResult
|
||||||
|
public func delete(id: String) throws -> Bool {
|
||||||
|
try load()
|
||||||
|
let before = recipes.count
|
||||||
|
let updated = recipes.filter { $0.id != id }
|
||||||
|
guard updated.count != before else { return false }
|
||||||
|
try fileStore.save(updated)
|
||||||
|
recipes = updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MediaLibraryError: LocalizedError, Equatable {
|
||||||
|
case capacityReached(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .capacityReached(let cap):
|
||||||
|
return "Media library is full (\(cap)). Delete a recipe first."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A media recipe: a named binding of CUPS queue + paper + ink set +
|
||||||
|
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
|
||||||
|
/// library).
|
||||||
|
///
|
||||||
|
/// snake_case keys match the v1 JSON schema so `media_library.json`
|
||||||
|
/// stays import/export compatible. Identity + binding fields are
|
||||||
|
/// required; every other field is optional-defaulted. Unknown keys are
|
||||||
|
/// ignored on decode; missing required fields fail the whole array
|
||||||
|
/// decode (corrupt-file policy, never silently dropped).
|
||||||
|
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
|
||||||
|
/// `"recipe-<uuid>"`, never user-typed.
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
public var notes: String
|
||||||
|
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
|
||||||
|
public var printerID: String
|
||||||
|
/// Human label from `Printer.displayName`.
|
||||||
|
public var printerDisplayName: String
|
||||||
|
/// Library metadata only — never written to targen `-P`/`-I` flags.
|
||||||
|
public var paperName: String
|
||||||
|
/// Last captured CUPS `media_type`, read-only.
|
||||||
|
public var driverMediaType: String?
|
||||||
|
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
|
||||||
|
public var inkSet: String
|
||||||
|
/// `"rgb"` | `"cmyk"` — must match the bound preset.
|
||||||
|
public var colourSpace: String
|
||||||
|
/// `ProfilingPreset.id` (built-in or custom).
|
||||||
|
public var presetID: String
|
||||||
|
/// Absolute `.cal` path stored verbatim; `nil` = none.
|
||||||
|
public var calibrationURL: String?
|
||||||
|
public var applyCalibration: Bool
|
||||||
|
public var created: Date
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
notes: String = "",
|
||||||
|
printerID: String,
|
||||||
|
printerDisplayName: String = "",
|
||||||
|
paperName: String = "",
|
||||||
|
driverMediaType: String? = nil,
|
||||||
|
inkSet: String = "",
|
||||||
|
colourSpace: String,
|
||||||
|
presetID: String,
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
applyCalibration: Bool = false,
|
||||||
|
created: Date = Date(),
|
||||||
|
updated: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.notes = notes
|
||||||
|
self.printerID = printerID
|
||||||
|
self.printerDisplayName = printerDisplayName
|
||||||
|
self.paperName = paperName
|
||||||
|
self.driverMediaType = driverMediaType
|
||||||
|
self.inkSet = inkSet
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.presetID = presetID
|
||||||
|
self.calibrationURL = calibrationURL
|
||||||
|
self.applyCalibration = applyCalibration
|
||||||
|
self.created = created
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, notes
|
||||||
|
case printerID = "printer_id"
|
||||||
|
case printerDisplayName = "printer_display_name"
|
||||||
|
case paperName = "paper_name"
|
||||||
|
case driverMediaType = "driver_media_type"
|
||||||
|
case inkSet = "ink_set"
|
||||||
|
case colourSpace = "colour_space"
|
||||||
|
case presetID = "preset_id"
|
||||||
|
case calibrationURL = "calibration_url"
|
||||||
|
case applyCalibration = "apply_calibration"
|
||||||
|
case created, updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: required identity + binding fields must be
|
||||||
|
/// present; optionals default. Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
printerID = try c.decode(String.self, forKey: .printerID)
|
||||||
|
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
|
||||||
|
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
|
||||||
|
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
|
||||||
|
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
|
||||||
|
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||||
|
presetID = try c.decode(String.self, forKey: .presetID)
|
||||||
|
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
|
||||||
|
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
|
||||||
|
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
|
||||||
|
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case emptyName
|
||||||
|
case emptyPrinterID
|
||||||
|
case invalidColourSpace(String)
|
||||||
|
case emptyPresetID
|
||||||
|
case invalidCalibrationURL(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyName: return "Media recipe is missing a name."
|
||||||
|
case .emptyPrinterID: return "Media recipe is missing a printer."
|
||||||
|
case .invalidColourSpace(let v):
|
||||||
|
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||||
|
case .emptyPresetID: return "Media recipe is missing a preset."
|
||||||
|
case .invalidCalibrationURL(let v):
|
||||||
|
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the binding fields. `colourSpace` is normalized to
|
||||||
|
/// lowercase before comparison. `CAL_` cal names are **not**
|
||||||
|
/// rejected — that is an apply-time policy, not schema.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> MediaRecipe {
|
||||||
|
var r = self
|
||||||
|
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.colourSpace = colourSpace.lowercased()
|
||||||
|
guard !r.name.isEmpty else { throw ValidationError.emptyName }
|
||||||
|
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
|
||||||
|
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
|
||||||
|
throw ValidationError.invalidColourSpace(colourSpace)
|
||||||
|
}
|
||||||
|
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
|
||||||
|
if let cal = r.calibrationURL, !cal.isEmpty {
|
||||||
|
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
|
||||||
|
throw ValidationError.invalidCalibrationURL(cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `Save Report…` — a one-page UTF-8 Markdown summary written next to
|
||||||
|
/// the artefacts as `{basename}-report.md` (issue #149). Generated
|
||||||
|
/// output, safe to overwrite. Filenames only — never absolute paths —
|
||||||
|
/// and no HTML.
|
||||||
|
public enum ProjectReport {
|
||||||
|
|
||||||
|
/// `{cwd}/{basename}-report.md`.
|
||||||
|
public static func url(for project: ICCeryProject) -> URL {
|
||||||
|
URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||||
|
.appendingPathComponent("\(project.basename)-report.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the report. `artefacts` is the live probe result so the
|
||||||
|
/// checklist shows what is actually on disk, not what the project
|
||||||
|
/// JSON claims (R18).
|
||||||
|
public static func markdown(
|
||||||
|
project: ICCeryProject,
|
||||||
|
recipeName: String?,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> String {
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.append("# \(project.name)")
|
||||||
|
lines.append("")
|
||||||
|
if let printer = project.printerDisplayName ?? project.printerID,
|
||||||
|
!printer.isEmpty {
|
||||||
|
lines.append("- **Printer:** \(printer)")
|
||||||
|
}
|
||||||
|
if let recipeName, !recipeName.isEmpty {
|
||||||
|
lines.append("- **Media recipe:** \(recipeName)")
|
||||||
|
}
|
||||||
|
if let preset = project.presetID, !preset.isEmpty {
|
||||||
|
lines.append("- **Preset:** \(preset)")
|
||||||
|
}
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## Artefacts")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| File | Status |")
|
||||||
|
lines.append("|------|--------|")
|
||||||
|
lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete))
|
||||||
|
lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete))
|
||||||
|
lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete))
|
||||||
|
let profileName = artefacts.profilePath?.lastPathComponent
|
||||||
|
?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)"
|
||||||
|
lines.append(row(profileName, exists: artefacts.stage4Complete))
|
||||||
|
if let gam = artefacts.gamPath {
|
||||||
|
lines.append(row(gam.lastPathComponent, exists: true))
|
||||||
|
}
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if let verification = project.lastVerification {
|
||||||
|
lines.append("## Last verification")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))"
|
||||||
|
+ " (\(verification.status))")
|
||||||
|
lines.append("- Profile: \(verification.profileFilename)")
|
||||||
|
lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))")
|
||||||
|
lines.append("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !project.notes.isEmpty {
|
||||||
|
lines.append("## Notes")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(project.notes)
|
||||||
|
lines.append("")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `{cwd}/{basename}-report.md` atomically, overwriting an
|
||||||
|
/// existing generated report.
|
||||||
|
@discardableResult
|
||||||
|
public static func write(
|
||||||
|
project: ICCeryProject,
|
||||||
|
recipeName: String?,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) throws -> URL {
|
||||||
|
let destination = url(for: project)
|
||||||
|
try AtomicFileWriter.write(
|
||||||
|
markdown(project: project, recipeName: recipeName, artefacts: artefacts),
|
||||||
|
to: destination)
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func row(_ filename: String, exists: Bool) -> String {
|
||||||
|
"| \(filename) | \(exists ? "exists" : "missing") |"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func f(_ value: Double) -> String {
|
||||||
|
String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One row in `recent_projects.json` — bookmark `Data` *and* the plain
|
||||||
|
/// absolute path so the entry still resolves when the bookmark can't.
|
||||||
|
public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
public var name: String
|
||||||
|
/// Absolute path to the `.icceryproj` file.
|
||||||
|
public var path: String
|
||||||
|
/// File bookmark; optional — path is the fallback.
|
||||||
|
public var bookmark: Data?
|
||||||
|
public var updated: Date
|
||||||
|
|
||||||
|
public var id: String { path }
|
||||||
|
|
||||||
|
/// Stable digest of `path` for `projectRecent-{hash}` menu ids —
|
||||||
|
/// FNV-1a 64, stable across launches unlike `hashValue`.
|
||||||
|
public var bookmarkHash: String {
|
||||||
|
var hash: UInt64 = 0xcbf29ce484222325
|
||||||
|
for byte in path.utf8 {
|
||||||
|
hash ^= UInt64(byte)
|
||||||
|
hash &*= 0x100000001b3
|
||||||
|
}
|
||||||
|
return String(hash, radix: 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) {
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.bookmark = bookmark
|
||||||
|
self.updated = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, path, bookmark, updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent-projects list (issue #149). Lives in
|
||||||
|
/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside
|
||||||
|
/// the project file (R12).
|
||||||
|
///
|
||||||
|
/// Cap 20, newest first, deduplicated by path. Entries whose file is
|
||||||
|
/// gone are dropped by `pruneMissing()` (called when the Open Recent
|
||||||
|
/// submenu builds). A corrupt file throws on load and is never
|
||||||
|
/// overwritten — the view model shows an empty list and keeps the
|
||||||
|
/// bytes (`.throwCorrupt`, R12).
|
||||||
|
public actor RecentProjectsStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 20
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var entries: [RecentProjectEntry] = []
|
||||||
|
|
||||||
|
/// Explicit load flag — an empty file is still "loaded".
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[RecentProjectEntry]>
|
||||||
|
private let fileManager: FileManager
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"),
|
||||||
|
capacity: Int = defaultCapacity,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads entries from disk. Returns the existing cache if already
|
||||||
|
/// loaded. Throws when the file exists but cannot be parsed; the
|
||||||
|
/// existing file is never overwritten in that case.
|
||||||
|
public func load() throws -> [RecentProjectEntry] {
|
||||||
|
guard !loaded else { return entries }
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else {
|
||||||
|
loaded = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
entries = try fileStore.load()
|
||||||
|
loaded = true
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all cached entries, newest first.
|
||||||
|
public func all() -> [RecentProjectEntry] {
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a project to the front, deduplicating by path and
|
||||||
|
/// trimming to `capacity`. Writes atomically.
|
||||||
|
///
|
||||||
|
/// Loads the existing list first and propagates any load error so
|
||||||
|
/// an unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] {
|
||||||
|
try load()
|
||||||
|
let bookmark = try? fileURL.bookmarkData()
|
||||||
|
var updated = entries.filter { $0.path != fileURL.path }
|
||||||
|
updated.insert(
|
||||||
|
RecentProjectEntry(
|
||||||
|
name: name,
|
||||||
|
path: fileURL.path,
|
||||||
|
bookmark: bookmark,
|
||||||
|
updated: Date()),
|
||||||
|
at: 0)
|
||||||
|
if updated.count > capacity {
|
||||||
|
updated = Array(updated.prefix(capacity))
|
||||||
|
}
|
||||||
|
try fileStore.save(updated)
|
||||||
|
entries = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an entry by path and writes atomically. Returns false
|
||||||
|
/// when no entry with that path exists.
|
||||||
|
@discardableResult
|
||||||
|
public func remove(path: String) throws -> Bool {
|
||||||
|
try load()
|
||||||
|
let before = entries.count
|
||||||
|
let updated = entries.filter { $0.path != path }
|
||||||
|
guard updated.count != before else { return false }
|
||||||
|
try fileStore.save(updated)
|
||||||
|
entries = updated
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Clear Menu` — wipes the recents file only; `.icceryproj` files
|
||||||
|
/// are never deleted (R12).
|
||||||
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
|
try fileStore.save([])
|
||||||
|
entries = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops entries whose file is gone and rewrites the store.
|
||||||
|
/// Called when the Open Recent submenu builds — missing files are
|
||||||
|
/// dropped there, not at launch (issue #149).
|
||||||
|
@discardableResult
|
||||||
|
public func pruneMissing() throws -> [RecentProjectEntry] {
|
||||||
|
try load()
|
||||||
|
let kept = entries.filter {
|
||||||
|
fileManager.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
if kept.count != entries.count {
|
||||||
|
try fileStore.save(kept)
|
||||||
|
entries = kept
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Line classifier for `spotread` stdout (issue #148).
|
||||||
|
///
|
||||||
|
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
|
||||||
|
/// this wraps `ChartreadClassifier` and only intercepts the lines that
|
||||||
|
/// would otherwise misclassify:
|
||||||
|
///
|
||||||
|
/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:`
|
||||||
|
/// continuation lines that trail the calibration and spot prompts —
|
||||||
|
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
|
||||||
|
/// - `Place instrument on a spot to be measured,` /
|
||||||
|
/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read
|
||||||
|
/// prompt; the generic chartread matcher does not know "take a
|
||||||
|
/// reading").
|
||||||
|
///
|
||||||
|
/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by
|
||||||
|
/// `SpotReadParser`, not classified here.
|
||||||
|
public enum SpotReadClassifier {
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
// Spot-read prompt continuations keep the current prompt state.
|
||||||
|
if previousState == .calibrating || previousState == .awaitingStrip {
|
||||||
|
if text.contains("hit any key")
|
||||||
|
|| text.contains("hit space")
|
||||||
|
|| text.contains("esc or")
|
||||||
|
|| text.contains("abort")
|
||||||
|
|| text.contains("to abort") {
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Place instrument on a spot to be measured," /
|
||||||
|
// " and hit a key to take a reading," — the Read trigger prompt.
|
||||||
|
if text.contains("spot to be measured")
|
||||||
|
|| text.contains("take a reading")
|
||||||
|
|| text.contains("measure the spot") {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifier.classify(line: line, previousState: previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One patch measurement emitted by a running `spotread` child (#148).
|
||||||
|
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public var id: UUID
|
||||||
|
public var timestamp: Date
|
||||||
|
/// D50 Lab (always present — derived from XYZ when needed).
|
||||||
|
public var lab: LabColor
|
||||||
|
/// XYZ on the 0–100 scale used by the fork, when the line carried it.
|
||||||
|
public var xyz: XYZColor?
|
||||||
|
public var instrumentName: String
|
||||||
|
public var port: Int?
|
||||||
|
/// Diagnostics only — never rendered as HTML or shown in the table.
|
||||||
|
public var rawLine: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
lab: LabColor,
|
||||||
|
xyz: XYZColor? = nil,
|
||||||
|
instrumentName: String = "",
|
||||||
|
port: Int? = nil,
|
||||||
|
rawLine: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.lab = lab
|
||||||
|
self.xyz = xyz
|
||||||
|
self.instrumentName = instrumentName
|
||||||
|
self.port = port
|
||||||
|
self.rawLine = rawLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `spotread` result lines into Lab / XYZ triples.
|
||||||
|
///
|
||||||
|
/// The fork's line shape is the upstream
|
||||||
|
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
|
||||||
|
/// also parses, and an XYZ-only line derives Lab via
|
||||||
|
/// `LabColorMath.xyzToLab` (D50).
|
||||||
|
public enum SpotReadParser {
|
||||||
|
|
||||||
|
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
|
||||||
|
guard line.range(of: "result is", options: .caseInsensitive) != nil
|
||||||
|
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|
||||||
|
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var xyz: XYZColor?
|
||||||
|
var lab: LabColor?
|
||||||
|
|
||||||
|
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
|
||||||
|
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
|
||||||
|
}
|
||||||
|
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
|
||||||
|
lab = LabColor(l: m.0, a: m.1, b: m.2)
|
||||||
|
}
|
||||||
|
if lab == nil, let xyz {
|
||||||
|
lab = LabColorMath.xyzToLab(xyz)
|
||||||
|
}
|
||||||
|
guard let lab else { return nil }
|
||||||
|
return (xyz, lab)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
|
||||||
|
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
|
||||||
|
match.numberOfRanges == 4,
|
||||||
|
let r1 = Range(match.range(at: 1), in: line),
|
||||||
|
let r2 = Range(match.range(at: 2), in: line),
|
||||||
|
let r3 = Range(match.range(at: 3), in: line),
|
||||||
|
let a = Double(line[r1]),
|
||||||
|
let b = Double(line[r2]),
|
||||||
|
let c = Double(line[r3])
|
||||||
|
else { return nil }
|
||||||
|
return (a, b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import Foundation
|
|||||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||||
public enum ProcessID {
|
public enum ProcessID {
|
||||||
public static let instlist = "instlist"
|
public static let instlist = "instlist"
|
||||||
|
/// Spot-read console (issue #148) — single lease, like `instlist`.
|
||||||
|
public static let spotread = "spotread"
|
||||||
|
|
||||||
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||||
|
|||||||
@@ -1,43 +1,75 @@
|
|||||||
# ICCery
|
# ICCery
|
||||||
|
|
||||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
|
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from
|
||||||
|
chart generation through measurement, `colprof`, verification, and ColorSync
|
||||||
|
install. It is **not** a colour engine.
|
||||||
|
|
||||||
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
**End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
|
||||||
|
covers every screen (Getting Started through Troubleshooting). This README is
|
||||||
|
for building, packaging, and contributing.
|
||||||
|
|
||||||
|
All measurement, chart generation, and profile mathematics live in the
|
||||||
|
[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned
|
||||||
|
as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product | ICCery v2 for macOS |
|
| Product | ICCery v2 for macOS |
|
||||||
| Bundle | `com.gronod.iccery2` |
|
| Bundle | `com.gronod.iccery2` |
|
||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| Version | 2.0.0 |
|
||||||
|
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||||
|
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||||
|
| CI | Gitea Actions `macos-12` runner |
|
||||||
| Default branch | `develop` |
|
| Default branch | `develop` |
|
||||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
|
||||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
| M7 | Pre-UAT hardening — shipped |
|
||||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||||
|
| M10 | Studio workflow: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
The wizard is artefact-gated:
|
The wizard is artefact-gated:
|
||||||
|
|
||||||
1. **Stage 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore
|
1. **Stage 1** — `targen` → `.ti1`
|
||||||
2. **Stage 1** — `targen` → `.ti1`
|
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
||||||
3. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
||||||
4. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
||||||
5. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||||
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
|
||||||
|
|
||||||
Plus CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging.
|
Plus:
|
||||||
|
|
||||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
- **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename
|
||||||
|
- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv`
|
||||||
|
- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal`
|
||||||
|
- **Spot Read** — live one-patch Lab/XYZ from the instrument
|
||||||
|
- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE
|
||||||
|
- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect
|
||||||
|
- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging
|
||||||
|
- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma)
|
||||||
|
|
||||||
|
**Not this product:** display calibration (`dispwin` / `dispread`), i18n,
|
||||||
|
Windows/Linux print trees, in-process Argyll, App Sandbox.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 14+
|
To **run** a packaged build:
|
||||||
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
|
|
||||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
|
||||||
- Network once, to fetch Argyll sidecars
|
|
||||||
|
|
||||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
- macOS 12.0 Monterey or later (Intel or Apple silicon)
|
||||||
|
|
||||||
|
To **build** on the supported CI/host floor:
|
||||||
|
|
||||||
|
- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7)
|
||||||
|
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current
|
||||||
|
formula needs Xcode 15.3; CI installs the pinned zip via
|
||||||
|
`scripts/ensure-host-tools.sh`)
|
||||||
|
- Network once, to fetch Argyll sidecars
|
||||||
|
- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild`
|
||||||
|
(`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`)
|
||||||
|
|
||||||
|
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in
|
||||||
|
`ICCery.entitlements`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -47,7 +79,7 @@ cd iccery-v2-mac
|
|||||||
git checkout develop
|
git checkout develop
|
||||||
|
|
||||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
||||||
make test # xcodegen + xcodebuild build test
|
make test # xcodegen + xcodebuild build test (host arch)
|
||||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,10 +89,17 @@ Equivalent without Make:
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`. CI and `make test` override
|
||||||
|
that with `ARCHS="$(uname -m)"` so unit/UI tests build the host slice only.
|
||||||
|
Fat binaries are `make universal` / `scripts/package-release.sh`.
|
||||||
|
|
||||||
|
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or
|
||||||
|
`ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts
|
||||||
|
to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if
|
||||||
|
`codesign -dvv` or the `instlist` marker is missing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# optional
|
# optional
|
||||||
@@ -70,9 +109,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
|
|||||||
export GITEA_TOKEN=… # private releases
|
export GITEA_TOKEN=… # private releases
|
||||||
```
|
```
|
||||||
|
|
||||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and
|
||||||
|
`Packages/ICCeryCore/.build`.
|
||||||
|
|
||||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
|
Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
|
||||||
|
and regenerate.
|
||||||
|
|
||||||
## Release packaging
|
## Release packaging
|
||||||
|
|
||||||
@@ -80,11 +121,20 @@ Do not open the generated xcodeproj as the source of truth. Edit `project.yml` a
|
|||||||
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
||||||
```
|
```
|
||||||
|
|
||||||
The script builds with a fixed derived data path (`build/DerivedData`),
|
The script builds with a fixed derived data path (`build/DerivedData`), locates
|
||||||
locates `Release/ICCery.app` from it, signs the bundle, recursively verifies
|
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||||
every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and
|
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay
|
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||||
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed.
|
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
|
||||||
|
|
||||||
|
Sidecars stay ad-hoc signed inside the bundle — the app is never
|
||||||
|
`codesign --deep`ed.
|
||||||
|
|
||||||
|
`dmgbuild` is **not** a test-job dependency. The package job sets
|
||||||
|
`INSTALL_DMGBUILD=1` so `scripts/ensure-host-tools.sh` creates
|
||||||
|
`build/.venv-dmgbuild`. On the Monterey runner (Python 3.9) that install uses
|
||||||
|
`PIP_IGNORE_REQUIRES_PYTHON=1` and pins `pip>=24.3,<26.1` (pip 26.1+ needs
|
||||||
|
3.10). Missing background art is a hard fail (#95).
|
||||||
|
|
||||||
Environment variables read by the pipeline:
|
Environment variables read by the pipeline:
|
||||||
|
|
||||||
@@ -106,28 +156,48 @@ Resources/ assets; Argyll reference files (not the tools)
|
|||||||
Vendor/Argyll/ fetched sidecars (gitignored)
|
Vendor/Argyll/ fetched sidecars (gitignored)
|
||||||
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
||||||
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
||||||
|
scripts/ensure-host-tools.sh
|
||||||
scripts/fetch-argyll.sh
|
scripts/fetch-argyll.sh
|
||||||
|
scripts/package-release.sh
|
||||||
docs/ functional spec + v2 ticket plan
|
docs/ functional spec + v2 ticket plan
|
||||||
```
|
```
|
||||||
|
|
||||||
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is not in this tree.
|
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
|
||||||
|
not in this tree.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
|
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
|
||||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`, etc.) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
|
||||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
|
is always set.
|
||||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
|
- **`ProcessManager` actor** owns child lifetime. Streaming tools
|
||||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
|
(`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
|
||||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
(`printcal`, `applycal`, CUPS) use `runCaptured`. Exclusive `ProcessID`
|
||||||
|
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
||||||
|
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
|
||||||
|
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
|
||||||
|
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
|
||||||
|
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
|
||||||
|
must not replace the input profile on cancel or non-zero exit.
|
||||||
|
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
|
||||||
|
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
|
||||||
|
`@Observable`.
|
||||||
|
- **Print.** Unmanaged `lp` with ColorSync suppression
|
||||||
|
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
|
||||||
|
options win over derived CUPS keys. Never `lp -o raw`.
|
||||||
|
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
||||||
|
limit. Split large `VStack`/`Group` trees (#146).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# full suite (universal)
|
# full suite (host arch) — same as CI
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
|
# fat compile-check (not the default test path):
|
||||||
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
# examples
|
# examples
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
@@ -136,9 +206,24 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
-only-testing:ICCeryUITests/Milestone5UITests
|
||||||
```
|
```
|
||||||
|
|
||||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
|
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||||
|
GitHub) runs `build-and-test` then `package` on
|
||||||
|
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||||
|
test job and still package. `pull_request` is wired for **`develop` only**.
|
||||||
|
The GitHub file is the same pipeline on `macos-14` (github.com retired
|
||||||
|
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
|
||||||
|
DMGs.
|
||||||
|
|
||||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||||
|
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||||
|
that a real `.gam` / `.icc` was extracted.
|
||||||
|
|
||||||
|
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are
|
||||||
|
manual and block release, not compile.
|
||||||
|
|
||||||
|
`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is
|
||||||
|
dropped on a fast mock exit; that is a `ProcessManager` drain race, not a
|
||||||
|
missing fixture.
|
||||||
|
|
||||||
## Instruments
|
## Instruments
|
||||||
|
|
||||||
@@ -149,11 +234,18 @@ Detected via bundled `instlist`:
|
|||||||
- SpyderPrint (`p3`)
|
- SpyderPrint (`p3`)
|
||||||
- SpectroScan (`SS`)
|
- SpectroScan (`SS`)
|
||||||
- DTP20 / 22 / 41 / 51
|
- DTP20 / 22 / 41 / 51
|
||||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
|
||||||
|
`/spectro\s?scan|i1io/i`
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
| Where | Audience |
|
||||||
|
|---|---|
|
||||||
|
| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting |
|
||||||
|
| [`docs/`](docs/README.md) | Functional spec (normative for implementers) |
|
||||||
|
| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules |
|
||||||
|
|
||||||
|
Implementation order in `docs/`:
|
||||||
|
|
||||||
| Doc | Topic |
|
| Doc | Topic |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -161,29 +253,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
|
|||||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
||||||
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
|
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
|
||||||
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
|
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
|
||||||
|
| [`docs/23-assets.md`](docs/23-assets.md) | Icons, DMG chrome |
|
||||||
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
|
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
|
||||||
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
|
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
|
||||||
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
|
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
|
||||||
|
|
||||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop # integration; PRs land here unless a milestone branch is announced
|
||||||
└── milestone/m8-consolidation # integration branch
|
main # protected release line (PR from develop)
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
feat/<issue>-<slug>
|
||||||
|
fix/<issue>-<slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
|
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||||
|
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
|
||||||
|
tickets.
|
||||||
|
|
||||||
|
`main` is push-protected and requires status check
|
||||||
|
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||||
|
block PR merges that touch matching paths — do not set that field to `*`.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
|
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing
|
||||||
|
and personal evaluation only unless a separate grant says otherwise.
|
||||||
|
|
||||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
|
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay
|
||||||
|
subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning
|
||||||
|
via `$PATH`, is a licence break.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
- [User wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
|
||||||
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
|
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
|
||||||
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
|||||||
struct AboutView: View {
|
struct AboutView: View {
|
||||||
let onClose: () -> Void
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var showingLicenses = false
|
||||||
|
|
||||||
private let info = ArtefactFiles.appInfo()
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("View Licenses") {
|
||||||
|
showingLicenses = true
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("viewLicensesBtn")
|
||||||
|
|
||||||
Button("Close") {
|
Button("Close") {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
|||||||
.background(Theme.panel)
|
.background(Theme.panel)
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("aboutDialog")
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
.sheet(isPresented: $showingLicenses) {
|
||||||
|
LicenseWindowView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct AppEnvironment: Sendable {
|
|||||||
let runner: ArgyllRunner
|
let runner: ArgyllRunner
|
||||||
let cupsService: CupsService
|
let cupsService: CupsService
|
||||||
let historyStore: VerificationHistoryStore
|
let historyStore: VerificationHistoryStore
|
||||||
|
let mediaStore: MediaLibraryStore
|
||||||
|
let recentProjectsStore: RecentProjectsStore
|
||||||
|
|
||||||
static func live(
|
static func live(
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
@@ -20,11 +22,15 @@ struct AppEnvironment: Sendable {
|
|||||||
let settingsStore = SettingsStore()
|
let settingsStore = SettingsStore()
|
||||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
.map { URL(fileURLWithPath: $0) }
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var bundledRoot = AppPaths.bundledArgyllDir
|
||||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
overrideDir = URL(fileURLWithPath: dir)
|
overrideDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
|
if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty {
|
||||||
|
bundledRoot = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
cupsDir = URL(fileURLWithPath: dir)
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
}
|
}
|
||||||
@@ -35,12 +41,15 @@ struct AppEnvironment: Sendable {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: .shared,
|
processManager: .shared,
|
||||||
binaryDir: cupsDir),
|
binaryDir: cupsDir),
|
||||||
historyStore: VerificationHistoryStore()
|
historyStore: VerificationHistoryStore(),
|
||||||
|
mediaStore: MediaLibraryStore(),
|
||||||
|
recentProjectsStore: RecentProjectsStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,26 @@ enum UITestHooks {
|
|||||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
/// Preset export destination.
|
/// Preset export destination.
|
||||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||||
|
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
|
||||||
|
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
|
||||||
|
/// `.gam` compare picker result (gamut sheet, #147). Unset → cancel.
|
||||||
|
static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") }
|
||||||
|
/// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel.
|
||||||
|
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
|
||||||
|
/// TIFF sample picker result (gamut sheet, #147). Unset → cancel.
|
||||||
|
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
|
||||||
|
/// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel.
|
||||||
|
static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") }
|
||||||
|
/// `selectProjectSavePath` result (`.icceryproj` save-as, #149).
|
||||||
|
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
|
||||||
|
/// Relocate-folder result when a project's `cwd` is missing (#149).
|
||||||
|
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
|
||||||
|
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
|
||||||
|
/// host (#147). Set per-test only — never in a default launch env,
|
||||||
|
/// or CI's future GPU run would skip SceneKit too.
|
||||||
|
static var skipSceneKit: Bool {
|
||||||
|
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
|||||||
@@ -7,102 +7,135 @@ struct CalibrationView: View {
|
|||||||
@ObservedObject var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("Calibrate Printer")
|
ScrollView {
|
||||||
.font(.title2.bold())
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.padding(.horizontal, 16)
|
Text("Calibrate Printer")
|
||||||
.padding(.top, 16)
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
Form {
|
wedgeSection
|
||||||
Section("Wedge Settings") {
|
workflowSection
|
||||||
Picker("Colour Space", selection: $model.colourSpace) {
|
if !model.calibrationLog.isEmpty { logSection }
|
||||||
Text("RGB").tag(ColourSpace.rgb)
|
|
||||||
Text("CMYK").tag(ColourSpace.cmyk)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Steps per channel")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.steps, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calSteps")
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("White patches")
|
|
||||||
Spacer()
|
|
||||||
TextField("", value: $model.whitePatches, format: .number)
|
|
||||||
.frame(width: 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.colourSpace == .cmyk {
|
|
||||||
HStack {
|
|
||||||
Text("Ink-limit exploration")
|
|
||||||
Spacer()
|
|
||||||
TextField("", text: $model.inkLimit)
|
|
||||||
.frame(width: 60)
|
|
||||||
.accessibilityIdentifier("calInkExplore")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Workflow") {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button("Generate Target") { model.generateTarget() }
|
|
||||||
.accessibilityIdentifier("btnCalGenerate")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Create Layout & Print") { model.createLayout() }
|
|
||||||
.accessibilityIdentifier("btnCalLayout")
|
|
||||||
.disabled(wizard.basename.isEmpty
|
|
||||||
|| wizard.effectiveWorkingDirectory == nil
|
|
||||||
|| model.isGenerating)
|
|
||||||
|
|
||||||
Button("Measure") { model.measureChart() }
|
|
||||||
.accessibilityIdentifier("btnCalMeasure")
|
|
||||||
.disabled(model.calibrationTi3URL == nil)
|
|
||||||
|
|
||||||
Button("Compute Curves") { model.computeCurves() }
|
|
||||||
.accessibilityIdentifier("btnCalCompute")
|
|
||||||
.disabled(!model.canCompute)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let url = model.computedCalURL {
|
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
|
||||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !model.calibrationLog.isEmpty {
|
|
||||||
Section("Log") {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
ForEach(model.calibrationLog, id: \.self) { line in
|
|
||||||
Text(line)
|
|
||||||
.font(.system(.caption, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minHeight: 80, maxHeight: 120)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Return to Profiling") { model.returnToProfiling() }
|
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
.accessibilityIdentifier("btnCalReturn")
|
.accessibilityIdentifier("btnCalReturn")
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("stage-cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wedge settings
|
||||||
|
|
||||||
|
private var wedgeSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
Picker("Colour Space", selection: $model.colourSpace) {
|
||||||
|
Text("RGB").tag(ColourSpace.rgb)
|
||||||
|
Text("CMYK").tag(ColourSpace.cmyk)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Steps per channel")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.steps, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calSteps")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("White patches")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", value: $model.whitePatches, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.colourSpace == .cmyk {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Ink-limit exploration")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(width: 140, alignment: .leading)
|
||||||
|
TextField("", text: $model.inkLimit)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
.accessibilityIdentifier("calInkExplore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("calNeutralEmphasis")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workflow
|
||||||
|
|
||||||
|
private var workflowSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Generate Target") { model.generateTarget() }
|
||||||
|
.accessibilityIdentifier("btnCalGenerate")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Create Layout & Print") { model.createLayout() }
|
||||||
|
.accessibilityIdentifier("btnCalLayout")
|
||||||
|
.disabled(wizard.basename.isEmpty
|
||||||
|
|| wizard.effectiveWorkingDirectory == nil
|
||||||
|
|| model.isGenerating)
|
||||||
|
|
||||||
|
Button("Measure") { model.measureChart() }
|
||||||
|
.accessibilityIdentifier("btnCalMeasure")
|
||||||
|
.disabled(model.calibrationTi3URL == nil)
|
||||||
|
|
||||||
|
Button("Compute Curves") { model.computeCurves() }
|
||||||
|
.accessibilityIdentifier("btnCalCompute")
|
||||||
|
.disabled(!model.canCompute)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = model.computedCalURL {
|
||||||
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log
|
||||||
|
|
||||||
|
private var logSection: some View {
|
||||||
|
ProcessLogView(
|
||||||
|
lines: model.calibrationLog,
|
||||||
|
minHeight: 80,
|
||||||
|
maxHeight: 120,
|
||||||
|
containerId: "calLogContainer",
|
||||||
|
logId: "calLog"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ final class FileDialogService {
|
|||||||
message: "Choose a calibration file (.cal)")
|
message: "Choose a calibration file (.cal)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147).
|
||||||
|
func selectGamutFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["gam"], startingAt: start,
|
||||||
|
message: "Choose a .gam surface mesh",
|
||||||
|
allowsOtherFileTypes: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147).
|
||||||
|
func selectTiffFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["tif", "tiff"], startingAt: start,
|
||||||
|
message: "Choose a target TIFF page")
|
||||||
|
}
|
||||||
|
|
||||||
/// `btnImportPreset` — open a `.json` preset file.
|
/// `btnImportPreset` — open a `.json` preset file.
|
||||||
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
open(extensions: ["json"], startingAt: start,
|
open(extensions: ["json"], startingAt: start,
|
||||||
@@ -82,6 +95,23 @@ final class FileDialogService {
|
|||||||
message: "Export this preset as JSON")
|
message: "Export this preset as JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `selectProjectFile` — open `.icceryproj` only (issue #149).
|
||||||
|
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["icceryproj"], startingAt: start,
|
||||||
|
message: "Open ICCery Project",
|
||||||
|
title: "Open ICCery Project",
|
||||||
|
allowsOtherFileTypes: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectProjectSavePath` — save `.icceryproj`, suggested name
|
||||||
|
/// `{basename}.icceryproj` in the working folder (issue #149).
|
||||||
|
func selectProjectSavePath(
|
||||||
|
basename: String, startingAt start: URL? = nil
|
||||||
|
) -> URL? {
|
||||||
|
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
|
||||||
|
startingAt: start, message: "Save ICCery Project")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internals (private — not a shared public picker API)
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
private func save(
|
private func save(
|
||||||
@@ -102,16 +132,19 @@ final class FileDialogService {
|
|||||||
private func open(
|
private func open(
|
||||||
extensions: [String],
|
extensions: [String],
|
||||||
startingAt start: URL?,
|
startingAt start: URL?,
|
||||||
message: String?
|
message: String?,
|
||||||
|
title: String? = nil,
|
||||||
|
allowsOtherFileTypes: Bool = true
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.allowsMultipleSelection = false
|
panel.allowsMultipleSelection = false
|
||||||
panel.allowedContentTypes = utTypes(extensions)
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
panel.allowsOtherFileTypes = true
|
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
||||||
panel.directoryURL = start
|
panel.directoryURL = start
|
||||||
if let message { panel.message = message }
|
if let message { panel.message = message }
|
||||||
|
if let title { panel.title = title }
|
||||||
return run(panel)
|
return run(panel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+498
-72
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SceneKit
|
import SceneKit
|
||||||
|
import Metal
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import simd
|
import simd
|
||||||
|
|
||||||
@@ -62,72 +63,322 @@ internal struct GamutSceneGeometryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native SceneKit 3D gamut viewer.
|
/// Native SceneKit 3D gamut viewer (issues #28, #147).
|
||||||
///
|
///
|
||||||
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
|
/// Displays the bundled `sRGB.gam` reference plus up to two profile
|
||||||
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
|
/// meshes with independent visibility toggles, a status line, and an
|
||||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
|
||||||
/// (blue-yellow) is depth.
|
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
|
||||||
|
/// `z = b*`.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@StateObject private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(
|
||||||
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
environment: AppEnvironment,
|
||||||
|
profileGamURL: URL? = nil,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
_viewModel = StateObject(wrappedValue: GamutViewModel(
|
||||||
|
environment: environment, profileGamURL: profileGamURL))
|
||||||
|
_showingAllHelp = showingAllHelp
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
VStack(spacing: 0) {
|
||||||
GamutSceneView(
|
toolbar
|
||||||
profileMesh: viewModel.profileMesh,
|
Divider().overlay(Theme.border)
|
||||||
referenceMesh: viewModel.sRGBMesh,
|
sceneArea
|
||||||
onReset: $viewModel.resetCamera,
|
Divider().overlay(Theme.border)
|
||||||
onPause: $pause
|
statusLine
|
||||||
)
|
inspectPanel
|
||||||
.focusable()
|
Divider().overlay(Theme.border)
|
||||||
.focused($isFocused)
|
footer
|
||||||
.onAppear { isFocused = true }
|
|
||||||
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(action: { viewModel.resetCamera() }) {
|
|
||||||
Text("Reset view")
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("btnResetGamutCamera")
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Text(viewModel.status)
|
|
||||||
.font(.caption)
|
|
||||||
.padding(8)
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.cornerRadius(6)
|
|
||||||
.accessibilityIdentifier("gamutStatusText")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, minHeight: 400)
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
|
.background(Theme.background)
|
||||||
.onDisappear { pause() }
|
.onDisappear { pause() }
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("gamutView")
|
.accessibilityIdentifier("gamutView")
|
||||||
|
.sheet(isPresented: $viewModel.showingTiffPreview) {
|
||||||
|
tiffPreviewSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
private var toolbar: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
|
||||||
|
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
|
||||||
|
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
|
||||||
|
Spacer()
|
||||||
|
addCompareMenu
|
||||||
|
Button("Remove compare") { viewModel.removeCompare() }
|
||||||
|
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
|
||||||
|
.accessibilityIdentifier("btnGamutRemoveCompare")
|
||||||
|
Button("Sample TIFF…") { viewModel.openTiffSample() }
|
||||||
|
.accessibilityIdentifier("btnGamutSampleTiff")
|
||||||
|
.helpOverlay(
|
||||||
|
"Sample a colour from a target TIFF page.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layerToggle(id: String, fallback: String) -> some View {
|
||||||
|
let layer = viewModel.layer(id: id)
|
||||||
|
return Toggle(isOn: Binding(
|
||||||
|
get: { layer != nil && viewModel.visibleIDs.contains(id) },
|
||||||
|
set: { on in
|
||||||
|
if on {
|
||||||
|
viewModel.visibleIDs.insert(id)
|
||||||
|
} else {
|
||||||
|
viewModel.visibleIDs.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text(layer?.displayName ?? fallback)
|
||||||
|
}
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.disabled(layer == nil)
|
||||||
|
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||||
|
// macOS 12 puts the identifier on the Toggle's container, an
|
||||||
|
// element that never reports isEnabled — combine so the a11y
|
||||||
|
// leaf is the checkbox itself.
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityIdentifier("gamutLayer-\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addCompareMenu: some View {
|
||||||
|
Menu("Add compare…") {
|
||||||
|
Button("Open .gam…") { viewModel.openCompareGam() }
|
||||||
|
.accessibilityIdentifier("btnGamutOpenGam")
|
||||||
|
Button("Open profile…") { viewModel.openCompareProfile() }
|
||||||
|
.accessibilityIdentifier("btnGamutOpenProfile")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnGamutAddCompare")
|
||||||
|
.helpOverlay(
|
||||||
|
"Add a second profile or .gam mesh to compare against.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scene
|
||||||
|
|
||||||
|
private var sceneArea: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
if viewModel.viewerUnavailable {
|
||||||
|
Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.accessibilityIdentifier("gamutViewerUnavailable")
|
||||||
|
} else {
|
||||||
|
GamutSceneView(
|
||||||
|
layers: viewModel.layers,
|
||||||
|
visibleIDs: viewModel.visibleIDs,
|
||||||
|
onReset: $viewModel.resetCamera,
|
||||||
|
onPause: $pause,
|
||||||
|
onUnavailable: { viewModel.viewerUnavailable = true },
|
||||||
|
onInspect: { point, layerID in
|
||||||
|
if let layerID {
|
||||||
|
viewModel.inspectSceneHit(world: point, layerID: layerID)
|
||||||
|
} else {
|
||||||
|
viewModel.clearInspect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.focusable()
|
||||||
|
.focused($isFocused)
|
||||||
|
.onAppear { isFocused = true }
|
||||||
|
}
|
||||||
|
Button(action: { viewModel.resetCamera() }) {
|
||||||
|
Text("Reset view")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnResetGamutCamera")
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status
|
||||||
|
|
||||||
|
private var statusLine: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(viewModel.status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("gamutStatusText")
|
||||||
|
if let notice = viewModel.noticeText {
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("gamutNoticeText")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspect panel
|
||||||
|
|
||||||
|
private var inspectPanel: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let lab = viewModel.inspectLab {
|
||||||
|
inspectSwatch
|
||||||
|
labReadout(lab)
|
||||||
|
containmentColumn
|
||||||
|
if viewModel.inspectIsApproximate {
|
||||||
|
Text("approx. Lab, not ColorSync")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("gamutInspectApprox")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Click the mesh, or enter Lab, to inspect.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("gamutInspectIdle")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
labEntryFields
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(minHeight: 56)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("gamutInspectPanel")
|
||||||
|
.helpOverlay(
|
||||||
|
"Inspect a Lab point against each loaded gamut.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var inspectSwatch: some View {
|
||||||
|
if let swatch = viewModel.inspectSwatch {
|
||||||
|
Color(red: swatch.r, green: swatch.g, blue: swatch.b)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border))
|
||||||
|
.accessibilityIdentifier("gamutInspectSwatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func labReadout(_ lab: LabColor) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(String(format: "L %.1f", lab.l))
|
||||||
|
.accessibilityIdentifier("gamutInspectL")
|
||||||
|
Text(String(format: "a %.1f", lab.a))
|
||||||
|
.accessibilityIdentifier("gamutInspectA")
|
||||||
|
Text(String(format: "b %.1f", lab.b))
|
||||||
|
.accessibilityIdentifier("gamutInspectB")
|
||||||
|
}
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var containmentColumn: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(viewModel.inspectResults, id: \.id) { result in
|
||||||
|
Text("\(result.name) \(containmentWord(result.containment))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("gamutInspect-\(result.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func containmentWord(_ containment: GamutContainment) -> String {
|
||||||
|
switch containment {
|
||||||
|
case .inside: return "in"
|
||||||
|
case .outside: return "out"
|
||||||
|
case .unknown: return "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var labEntryFields: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Lab 0–100 · ±128")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("L", text: $viewModel.labEntryL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 56)
|
||||||
|
.accessibilityIdentifier("gamutLabEntryL")
|
||||||
|
TextField("a", text: $viewModel.labEntryA)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 56)
|
||||||
|
.accessibilityIdentifier("gamutLabEntryA")
|
||||||
|
TextField("b", text: $viewModel.labEntryB)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 56)
|
||||||
|
.accessibilityIdentifier("gamutLabEntryB")
|
||||||
|
Button("Inspect") { viewModel.inspectEnteredLab() }
|
||||||
|
.disabled(!viewModel.canInspectLab)
|
||||||
|
.accessibilityIdentifier("btnGamutInspectLab")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
/// Always-visible Close (#147) — the fallback banner keeps it
|
||||||
|
/// reachable and Escape works via `.cancelAction` without SceneKit.
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("btnCloseGamut")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TIFF sample sheet
|
||||||
|
|
||||||
|
private var tiffPreviewSheet: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Click a pixel to sample its colour.")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
if let png = viewModel.tiffPreviewPNG {
|
||||||
|
TiffSampleImageView(pngData: png) { r, g, b in
|
||||||
|
viewModel.sampleTiffPixel(r: r, g: g, b: b)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 320, minHeight: 240)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { viewModel.showingTiffPreview = false }
|
||||||
|
.accessibilityIdentifier("btnCloseGamutTiffPreview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("gamutTiffPreview")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
|
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
|
||||||
/// one or two ``GamutMesh`` values.
|
/// per ``NamedGamut`` layer.
|
||||||
///
|
///
|
||||||
/// Scene construction and camera reset are coordinated through a typed callback
|
/// Layer toggles hide/show `SCNNode`s — the scene is built once and the
|
||||||
/// binding owned by the view model.
|
/// camera is only reset through the explicit reset path, never on a
|
||||||
|
/// mesh or visibility update.
|
||||||
private struct GamutSceneView: NSViewRepresentable {
|
private struct GamutSceneView: NSViewRepresentable {
|
||||||
var profileMesh: GamutMesh?
|
var layers: [NamedGamut]
|
||||||
var referenceMesh: GamutMesh?
|
var visibleIDs: Set<String>
|
||||||
var onReset: Binding<() -> Void>
|
var onReset: Binding<() -> Void>
|
||||||
var onPause: Binding<() -> Void>
|
var onPause: Binding<() -> Void>
|
||||||
|
var onUnavailable: () -> Void
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> SCNView {
|
func makeNSView(context: Context) -> SCNView {
|
||||||
let scnView = SCNView()
|
let scnView = SCNView()
|
||||||
@@ -142,14 +393,25 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
context.coordinator.scnView = scnView
|
context.coordinator.scnView = scnView
|
||||||
context.coordinator.scene = scene
|
context.coordinator.scene = scene
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.onInspect = onInspect
|
||||||
|
context.coordinator.buildSceneOnce()
|
||||||
|
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||||
context.coordinator.installKeyMonitor()
|
context.coordinator.installKeyMonitor()
|
||||||
|
context.coordinator.installClickGesture()
|
||||||
|
|
||||||
|
// Safety net only — the primary no-Metal check is
|
||||||
|
// `GamutSceneAvailability.isAvailable`, evaluated before this
|
||||||
|
// view is mounted. Never respawn the view in a loop.
|
||||||
|
if MTLCreateSystemDefaultDevice() == nil {
|
||||||
|
DispatchQueue.main.async { onUnavailable() }
|
||||||
|
}
|
||||||
|
|
||||||
return scnView
|
return scnView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: SCNView, context: Context) {
|
func updateNSView(_ nsView: SCNView, context: Context) {
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.onInspect = onInspect
|
||||||
|
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
@@ -165,6 +427,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
|
|
||||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
coordinator.removeKeyMonitor()
|
coordinator.removeKeyMonitor()
|
||||||
|
if let click = coordinator.clickGesture {
|
||||||
|
nsView.removeGestureRecognizer(click)
|
||||||
|
}
|
||||||
nsView.isPlaying = false
|
nsView.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +437,14 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
weak var scene: SCNScene?
|
weak var scene: SCNScene?
|
||||||
|
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
|
||||||
|
private(set) var clickGesture: NSClickGestureRecognizer?
|
||||||
private var keyMonitor: Any?
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
private let profileNode = SCNNode()
|
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
|
||||||
private let referenceGroup = SCNNode()
|
private var layerNodes: [String: SCNNode] = [:]
|
||||||
private let axisNode = SCNNode()
|
private let axisNode = SCNNode()
|
||||||
|
private let layerGroup = SCNNode()
|
||||||
private let cameraNode: SCNNode = {
|
private let cameraNode: SCNNode = {
|
||||||
let node = SCNNode()
|
let node = SCNNode()
|
||||||
node.camera = SCNCamera()
|
node.camera = SCNCamera()
|
||||||
@@ -184,33 +452,97 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return node
|
return node
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
|
/// Builds the static scene furniture exactly once — axis
|
||||||
guard let scene else { return }
|
/// scaffold, lights, camera home. Layer content lives under
|
||||||
|
/// `layerGroup` and is managed by `syncLayers`.
|
||||||
|
func buildSceneOnce() {
|
||||||
|
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
|
||||||
|
|
||||||
// Rebuild from scratch on every mesh change to avoid stale geometry.
|
|
||||||
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
scene.rootNode.addChildNode(axisNode)
|
scene.rootNode.addChildNode(axisNode)
|
||||||
scene.rootNode.addChildNode(profileNode)
|
scene.rootNode.addChildNode(layerGroup)
|
||||||
scene.rootNode.addChildNode(referenceGroup)
|
|
||||||
scene.rootNode.addChildNode(cameraNode)
|
scene.rootNode.addChildNode(cameraNode)
|
||||||
|
|
||||||
buildAxisScaffold()
|
buildAxisScaffold()
|
||||||
|
|
||||||
if let profile {
|
|
||||||
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
|
|
||||||
} else {
|
|
||||||
profileNode.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let reference {
|
|
||||||
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
|
|
||||||
referenceGroup.addChildNode(referenceMeshNode(reference))
|
|
||||||
}
|
|
||||||
|
|
||||||
addLights(to: scene)
|
addLights(to: scene)
|
||||||
resetCamera()
|
resetCamera()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconciles the node set with `layers` and `visibleIDs`.
|
||||||
|
///
|
||||||
|
/// New layers get a node; removed layers lose theirs; hidden
|
||||||
|
/// layers keep their mesh (`isHidden` only). Never rebuilds the
|
||||||
|
/// scene, so the camera is untouched by a checkbox toggle.
|
||||||
|
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<String>) {
|
||||||
|
guard scene != nil else { return }
|
||||||
|
let wanted = Set(layers.map { $0.id })
|
||||||
|
for (id, node) in layerNodes where !wanted.contains(id) {
|
||||||
|
node.removeFromParentNode()
|
||||||
|
layerNodes.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
for layer in layers {
|
||||||
|
if layerNodes[layer.id] == nil {
|
||||||
|
let node = makeLayerNode(for: layer)
|
||||||
|
layerNodes[layer.id] = node
|
||||||
|
layerGroup.addChildNode(node)
|
||||||
|
}
|
||||||
|
layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayerNode(for layer: NamedGamut) -> SCNNode {
|
||||||
|
let node: SCNNode
|
||||||
|
switch layer.role {
|
||||||
|
case .reference:
|
||||||
|
node = referenceMeshNode(layer.mesh)
|
||||||
|
case .profileA:
|
||||||
|
node = profileMeshNode(layer.mesh)
|
||||||
|
case .profileB:
|
||||||
|
node = compareMeshNode(layer.mesh)
|
||||||
|
}
|
||||||
|
node.name = layer.id
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Click inspect (#147)
|
||||||
|
|
||||||
|
/// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer`
|
||||||
|
/// only fires on a press+release in place, so orbit drags are
|
||||||
|
/// untouched.
|
||||||
|
func installClickGesture() {
|
||||||
|
guard let scnView, clickGesture == nil else { return }
|
||||||
|
let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:)))
|
||||||
|
scnView.addGestureRecognizer(gesture)
|
||||||
|
clickGesture = gesture
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleClick(_ gesture: NSClickGestureRecognizer) {
|
||||||
|
guard let scnView else { return }
|
||||||
|
let point = gesture.location(in: scnView)
|
||||||
|
for hit in scnView.hitTest(point, options: nil) {
|
||||||
|
if let layerID = layerID(for: hit.node) {
|
||||||
|
let world = hit.worldCoordinates
|
||||||
|
onInspect(
|
||||||
|
SIMD3<Float>(Float(world.x), Float(world.y), Float(world.z)),
|
||||||
|
layerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Axis scaffold / empty background → back to idle.
|
||||||
|
onInspect(.zero, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks the hit node's ancestor chain looking for a layer node.
|
||||||
|
private func layerID(for node: SCNNode) -> String? {
|
||||||
|
var current: SCNNode? = node
|
||||||
|
while let node = current {
|
||||||
|
if let name = node.name, layerNodes[name] != nil { return name }
|
||||||
|
current = node.parent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scene furniture (unchanged from #28)
|
||||||
|
|
||||||
private func addLights(to scene: SCNScene) {
|
private func addLights(to scene: SCNScene) {
|
||||||
let ambient = SCNNode()
|
let ambient = SCNNode()
|
||||||
ambient.light = SCNLight()
|
ambient.light = SCNLight()
|
||||||
@@ -351,7 +683,8 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
return SCNNode(geometry: geometry)
|
return SCNNode(geometry: geometry)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
|
/// Profile A: solid vertex-coloured surface.
|
||||||
|
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
let (geometry, _) = scnGeometry(for: mesh)
|
let (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
let material = SCNMaterial()
|
let material = SCNMaterial()
|
||||||
@@ -361,11 +694,26 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
material.isDoubleSided = true
|
material.isDoubleSided = true
|
||||||
geometry.materials = [material]
|
geometry.materials = [material]
|
||||||
|
|
||||||
let node = SCNNode(geometry: geometry)
|
return SCNNode(geometry: geometry)
|
||||||
node.name = name
|
|
||||||
return node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compare profile B: same vertex colours at ~30 % opacity so
|
||||||
|
/// overlaps with A and the sRGB reference stay readable.
|
||||||
|
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
|
let (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
|
let material = SCNMaterial()
|
||||||
|
material.lightingModel = .lambert
|
||||||
|
material.diffuse.contents = NSColor.white
|
||||||
|
material.transparency = 0.30
|
||||||
|
material.isDoubleSided = true
|
||||||
|
material.writesToDepthBuffer = false
|
||||||
|
geometry.materials = [material]
|
||||||
|
|
||||||
|
return SCNNode(geometry: geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundled sRGB reference: faint fill + structural edge lines.
|
||||||
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||||
let (geometry, _) = scnGeometry(for: mesh)
|
let (geometry, _) = scnGeometry(for: mesh)
|
||||||
|
|
||||||
@@ -488,3 +836,81 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Click-to-sample image view for the TIFF preview sheet (#147).
|
||||||
|
///
|
||||||
|
/// The TIFF is already decoded to PNG on the host side (#58); the view
|
||||||
|
/// reports 8-bit sRGB pixel values at the clicked point — the Lab
|
||||||
|
/// conversion is the documented approximate matrix helper, not a CMM.
|
||||||
|
private struct TiffSampleImageView: NSViewRepresentable {
|
||||||
|
let pngData: Data
|
||||||
|
var onSample: (Int, Int, Int) -> Void
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> TiffSampleNSView {
|
||||||
|
let view = TiffSampleNSView()
|
||||||
|
view.image = NSImage(data: pngData)
|
||||||
|
view.onSample = onSample
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: TiffSampleNSView, context: Context) {
|
||||||
|
nsView.onSample = onSample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TiffSampleNSView: NSView {
|
||||||
|
var image: NSImage? {
|
||||||
|
didSet {
|
||||||
|
bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||||
|
.flatMap { NSBitmapImageRep(cgImage: $0) }
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var onSample: ((Int, Int, Int) -> Void)?
|
||||||
|
private var bitmapRep: NSBitmapImageRep?
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
image?.size ?? NSSize(width: 320, height: 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill()
|
||||||
|
dirtyRect.fill()
|
||||||
|
guard let image else { return }
|
||||||
|
image.draw(in: imageRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let rep = bitmapRep else { return }
|
||||||
|
let rect = imageRect()
|
||||||
|
let location = convert(event.locationInWindow, from: nil)
|
||||||
|
guard rect.contains(location), rect.width > 0, rect.height > 0 else { return }
|
||||||
|
|
||||||
|
let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide))
|
||||||
|
// This view is not flipped: y grows up, bitmap rows grow down.
|
||||||
|
let y = rep.pixelsHigh - 1
|
||||||
|
- Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh))
|
||||||
|
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return }
|
||||||
|
|
||||||
|
guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return }
|
||||||
|
onSample?(
|
||||||
|
Int((color.redComponent * 255).rounded()),
|
||||||
|
Int((color.greenComponent * 255).rounded()),
|
||||||
|
Int((color.blueComponent * 255).rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aspect-fit rect of the image inside `bounds`.
|
||||||
|
private func imageRect() -> NSRect {
|
||||||
|
guard let image, image.size.width > 0, image.size.height > 0 else { return .zero }
|
||||||
|
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
|
||||||
|
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
return NSRect(
|
||||||
|
x: (bounds.width - size.width) / 2,
|
||||||
|
y: (bounds.height - size.height) / 2,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,318 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
import Metal
|
||||||
|
import simd
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer.
|
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||||
/// printer/profile `.gam` from the current working directory.
|
/// `SCNView` on a Metal-less machine can wedge the main thread, which
|
||||||
|
/// also stalls app quit behind the open sheet.
|
||||||
|
enum GamutSceneAvailability {
|
||||||
|
static var isAvailable: Bool {
|
||||||
|
if UITestHooks.skipSceneKit { return false }
|
||||||
|
return MTLCreateSystemDefaultDevice() != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
||||||
|
///
|
||||||
|
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||||
|
/// profile `.gam` when one exists, and an optional compare mesh the user
|
||||||
|
/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info
|
||||||
|
/// notice, never fatal (#24).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GamutViewModel: ObservableObject {
|
final class GamutViewModel: ObservableObject {
|
||||||
|
|
||||||
/// Parsed reference sRGB gamut mesh.
|
/// Stable layer ids — also the `gamutLayer-<id>` a11y suffixes.
|
||||||
@Published var sRGBMesh: GamutMesh?
|
static let srgbLayerID = "sRGB"
|
||||||
|
static let profileLayerID = "profile"
|
||||||
|
static let compareLayerID = "compare"
|
||||||
|
|
||||||
/// Parsed printer/profile gamut mesh.
|
/// Loaded meshes: bundled sRGB plus up to two profiles.
|
||||||
@Published var profileMesh: GamutMesh?
|
@Published var layers: [NamedGamut] = []
|
||||||
|
|
||||||
/// User-facing status line.
|
/// Layer ids currently shown in the scene. Toggling never unloads
|
||||||
|
/// the mesh — the `SCNNode` is hidden only.
|
||||||
|
@Published var visibleIDs: Set<String> = [srgbLayerID]
|
||||||
|
|
||||||
|
/// User-facing status line (`gamutStatusText`). Always non-empty
|
||||||
|
/// once set — `Milestone6GamutUITests` asserts it.
|
||||||
@Published var status = "Loading gamut…"
|
@Published var status = "Loading gamut…"
|
||||||
|
|
||||||
|
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
|
||||||
|
/// sits behind the sheet, so notices surface here instead.
|
||||||
|
@Published var noticeText: String?
|
||||||
|
|
||||||
|
/// Set when `SCNView` cannot create a render context; the scene is
|
||||||
|
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
|
||||||
|
@Published var viewerUnavailable = false
|
||||||
|
|
||||||
/// Closure injected into the SceneKit view to request a camera reset.
|
/// Closure injected into the SceneKit view to request a camera reset.
|
||||||
@Published var resetCamera: () -> Void = {}
|
@Published var resetCamera: () -> Void = {}
|
||||||
|
|
||||||
private let profileGamURL: URL?
|
// MARK: - Inspect panel
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
/// Lab point currently inspected, or `nil` for the idle state.
|
||||||
|
@Published var inspectLab: LabColor?
|
||||||
|
|
||||||
|
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
|
||||||
|
/// the inspected Lab.
|
||||||
|
@Published var inspectSwatch: DisplayRGB?
|
||||||
|
|
||||||
|
/// `true` when the swatch/Lab came from the approximate helper or a
|
||||||
|
/// typed value — drives the "approx. Lab, not ColorSync" caption.
|
||||||
|
@Published var inspectIsApproximate = false
|
||||||
|
|
||||||
|
/// Per-layer containment for `inspectLab`, in layer order.
|
||||||
|
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
|
||||||
|
|
||||||
|
/// Manual Lab entry fields (`gamutLabEntry*`).
|
||||||
|
@Published var labEntryL = ""
|
||||||
|
@Published var labEntryA = ""
|
||||||
|
@Published var labEntryB = ""
|
||||||
|
|
||||||
|
// MARK: - TIFF sampling
|
||||||
|
|
||||||
|
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
|
||||||
|
@Published var tiffPreviewPNG: Data?
|
||||||
|
@Published var showingTiffPreview = false
|
||||||
|
|
||||||
|
private let environment: AppEnvironment
|
||||||
|
private let profileGamURL: URL?
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||||
|
self.environment = environment
|
||||||
self.profileGamURL = profileGamURL
|
self.profileGamURL = profileGamURL
|
||||||
Task { await load() }
|
// Never let the view mount an SCNView without Metal (#147).
|
||||||
|
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||||
|
loadTask = Task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var loadTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Awaits the initial sRGB/profile load — used by tests.
|
||||||
|
func awaitInitialLoad() async {
|
||||||
|
await loadTask?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func layer(id: String) -> NamedGamut? {
|
||||||
|
layers.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initial load
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
do {
|
do {
|
||||||
let referenceURL = BinaryResolver().referenceGamut("sRGB")
|
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
|
||||||
let reference = try await parse(url: referenceURL)
|
let reference = try await parse(url: referenceURL)
|
||||||
sRGBMesh = reference
|
layers.append(NamedGamut(
|
||||||
|
id: Self.srgbLayerID,
|
||||||
if let profileGamURL {
|
displayName: "sRGB",
|
||||||
let profile = try await parse(url: profileGamURL)
|
role: .reference,
|
||||||
profileMesh = profile
|
mesh: reference,
|
||||||
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
|
sourceURL: referenceURL))
|
||||||
} else {
|
visibleIDs.insert(Self.srgbLayerID)
|
||||||
status = "sRGB reference gamut (\(reference.faces.count) faces)"
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
status = "Could not load gamut: \(error.localizedDescription)"
|
status = "Could not load gamut: \(error.localizedDescription)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let profileGamURL {
|
||||||
|
do {
|
||||||
|
let profile = try await parse(url: profileGamURL)
|
||||||
|
layers.append(NamedGamut(
|
||||||
|
id: Self.profileLayerID,
|
||||||
|
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
|
||||||
|
role: .profileA,
|
||||||
|
mesh: profile,
|
||||||
|
sourceURL: profileGamURL))
|
||||||
|
visibleIDs.insert(Self.profileLayerID)
|
||||||
|
} catch {
|
||||||
|
// #24 — a missing/unparseable profile mesh is info, not fatal.
|
||||||
|
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compare slot (profile B)
|
||||||
|
|
||||||
|
/// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot.
|
||||||
|
func openCompareGam() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutFileURL
|
||||||
|
: fileDialogs.selectGamutFile()
|
||||||
|
guard let url else { return }
|
||||||
|
Task { await loadCompareGam(url: url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam`
|
||||||
|
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
|
||||||
|
func openCompareProfile() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutProfileURL
|
||||||
|
: fileDialogs.selectProfileFile()
|
||||||
|
guard let url else { return }
|
||||||
|
Task { await loadCompareProfile(url: url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A.
|
||||||
|
func removeCompare() {
|
||||||
|
layers.removeAll { $0.id == Self.compareLayerID }
|
||||||
|
visibleIDs.remove(Self.compareLayerID)
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `url` into the compare slot. Internal for tests — the UI
|
||||||
|
/// reaches it through `openCompareGam` / `openCompareProfile`.
|
||||||
|
func loadCompareGam(url: URL) async {
|
||||||
|
do {
|
||||||
|
let mesh = try await parse(url: url)
|
||||||
|
installCompare(NamedGamut(
|
||||||
|
id: Self.compareLayerID,
|
||||||
|
displayName: url.deletingPathExtension().lastPathComponent,
|
||||||
|
role: .profileB,
|
||||||
|
mesh: mesh,
|
||||||
|
sourceURL: url))
|
||||||
|
} catch {
|
||||||
|
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a `.gam` file off the main actor so large meshes do not stall
|
/// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot.
|
||||||
/// the UI.
|
/// Internal for tests.
|
||||||
|
func loadCompareProfile(url: URL) async {
|
||||||
|
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
|
||||||
|
do {
|
||||||
|
if !FileManager.default.fileExists(atPath: gamURL.path) {
|
||||||
|
_ = try await environment.runner.runIccgamut(
|
||||||
|
config: IccgamutConfig(profileURL: url))
|
||||||
|
}
|
||||||
|
await loadCompareGam(url: gamURL)
|
||||||
|
} catch {
|
||||||
|
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A third profile replaces B — the compare slot never stacks and
|
||||||
|
/// sRGB is never touched.
|
||||||
|
private func installCompare(_ gamut: NamedGamut) {
|
||||||
|
if layers.contains(where: { $0.id == gamut.id }) {
|
||||||
|
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
|
||||||
|
}
|
||||||
|
layers.removeAll { $0.id == gamut.id }
|
||||||
|
layers.append(gamut)
|
||||||
|
visibleIDs.insert(gamut.id)
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status line
|
||||||
|
|
||||||
|
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
|
||||||
|
/// Unloaded layers are omitted; the volume clause appears only when
|
||||||
|
/// both volumes are finite and positive.
|
||||||
|
private func refreshStatus() {
|
||||||
|
var clauses = layers.map {
|
||||||
|
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
|
||||||
|
}
|
||||||
|
if let srgb = layer(id: Self.srgbLayerID),
|
||||||
|
let profile = layer(id: Self.profileLayerID) {
|
||||||
|
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
|
||||||
|
let profileVolume = GamutGeometry.volume(of: profile.mesh)
|
||||||
|
if srgbVolume.isFinite, srgbVolume > 0,
|
||||||
|
profileVolume.isFinite, profileVolume > 0 {
|
||||||
|
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspect
|
||||||
|
|
||||||
|
/// Whether every manual Lab field parses as a number; the Inspect
|
||||||
|
/// button is disabled while this is false.
|
||||||
|
var canInspectLab: Bool {
|
||||||
|
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs containment for a Lab point and publishes the inspect row.
|
||||||
|
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
|
||||||
|
inspectLab = lab
|
||||||
|
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
|
||||||
|
inspectIsApproximate = isApproximate
|
||||||
|
inspectResults = layers.map {
|
||||||
|
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on the axis scaffold or empty background returns the panel
|
||||||
|
/// to idle.
|
||||||
|
func clearInspect() {
|
||||||
|
inspectLab = nil
|
||||||
|
inspectSwatch = nil
|
||||||
|
inspectIsApproximate = false
|
||||||
|
inspectResults = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`.
|
||||||
|
/// The swatch is the nearest vertex colour of the hit layer's mesh.
|
||||||
|
func inspectSceneHit(world: SIMD3<Float>, layerID: String) {
|
||||||
|
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
|
||||||
|
var swatch: DisplayRGB?
|
||||||
|
var approximate = true
|
||||||
|
if let mesh = layer(id: layerID)?.mesh,
|
||||||
|
let nearest = mesh.vertices.min(by: {
|
||||||
|
simd_distance($0.position, world) < simd_distance($1.position, world)
|
||||||
|
}) {
|
||||||
|
swatch = nearest.rgb
|
||||||
|
approximate = false
|
||||||
|
}
|
||||||
|
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis
|
||||||
|
/// values still run containment and report `?` outside every hull.
|
||||||
|
func inspectEnteredLab() {
|
||||||
|
guard let l = Double(labEntryL),
|
||||||
|
let a = Double(labEntryA),
|
||||||
|
let b = Double(labEntryB) else { return }
|
||||||
|
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TIFF sampling
|
||||||
|
|
||||||
|
/// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG
|
||||||
|
/// preview (#58), and open the click-to-sample sheet.
|
||||||
|
func openTiffSample() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.gamutTiffURL
|
||||||
|
: fileDialogs.selectTiffFile()
|
||||||
|
guard let url else { return }
|
||||||
|
guard let png = TiffPreview.previewPNG(tiff: url) else {
|
||||||
|
noticeText = "Could not decode TIFF preview."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tiffPreviewPNG = png
|
||||||
|
showingTiffPreview = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50.
|
||||||
|
func sampleTiffPixel(r: Int, g: Int, b: Int) {
|
||||||
|
inspect(
|
||||||
|
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
|
||||||
|
swatch: DisplayRGB(
|
||||||
|
r: Double(r) / 255.0,
|
||||||
|
g: Double(g) / 255.0,
|
||||||
|
b: Double(b) / 255.0),
|
||||||
|
isApproximate: true)
|
||||||
|
showingTiffPreview = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `.gam` file off the main actor so large meshes do not
|
||||||
|
/// stall the UI.
|
||||||
private func parse(url: URL) async throws -> GamutMesh {
|
private func parse(url: URL) async throws -> GamutMesh {
|
||||||
try await Task.detached {
|
try await Task.detached {
|
||||||
try GamutMeshParser.parse(url: url)
|
try GamutMeshParser.parse(url: url)
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ struct ICCeryApp: App {
|
|||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
// Single-window app: no File > New window.
|
// Single-window app: the File menu carries the project
|
||||||
CommandGroup(replacing: .newItem) {}
|
// commands (issue #149). Replacing `.newItem` keeps
|
||||||
|
// SwiftUI's empty default New from stacking (R19 — the
|
||||||
|
// group is filled, so no second New appears).
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
ProjectCommands(workflow: workflow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||||
|
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
|
||||||
|
/// not get a window that hangs off-screen — XCTest then reports
|
||||||
|
/// sidebar controls at negative x as not hittable (run 34864198118).
|
||||||
private func configureMainWindow(_ window: NSWindow) {
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
let desired = NSSize(width: 1280, height: 800)
|
||||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
let minimum = NSSize(width: 1100, height: 700)
|
||||||
|
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
|
||||||
|
?? NSRect(origin: .zero, size: desired)
|
||||||
|
|
||||||
|
window.contentMinSize = NSSize(
|
||||||
|
width: min(minimum.width, visible.width),
|
||||||
|
height: min(minimum.height, visible.height)
|
||||||
|
)
|
||||||
|
window.setContentSize(NSSize(
|
||||||
|
width: min(desired.width, visible.width),
|
||||||
|
height: min(desired.height, max(minimum.height, visible.height - 40))
|
||||||
|
))
|
||||||
window.center()
|
window.center()
|
||||||
|
|
||||||
|
var frame = window.frame
|
||||||
|
if frame.width > visible.width {
|
||||||
|
frame.size.width = visible.width
|
||||||
|
}
|
||||||
|
if frame.height > visible.height {
|
||||||
|
frame.size.height = visible.height
|
||||||
|
}
|
||||||
|
frame.origin.x = min(
|
||||||
|
max(frame.origin.x, visible.minX),
|
||||||
|
visible.maxX - frame.width
|
||||||
|
)
|
||||||
|
frame.origin.y = min(
|
||||||
|
max(frame.origin.y, visible.minY),
|
||||||
|
visible.maxY - frame.height
|
||||||
|
)
|
||||||
|
window.setFrame(frame, display: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
|
||||||
|
struct LicenseWindowView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let icceryLicense: String
|
||||||
|
private let argyllLicense: String
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// ICCery license from LICENCE.md (embedded at compile time)
|
||||||
|
self.icceryLicense = """
|
||||||
|
# LICENCE
|
||||||
|
|
||||||
|
**Copyright (c) 2026 Gordon Bolton**
|
||||||
|
**All Rights Reserved.**
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||||
|
|
||||||
|
Under this licence, you may **not**:
|
||||||
|
|
||||||
|
* Modify, alter, or create derivative works of the Software.
|
||||||
|
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||||
|
* Use the Software for any commercial or production purpose.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundled ArgyllCMS sidecar binaries
|
||||||
|
|
||||||
|
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
// ArgyllCMS license from bundled License.txt
|
||||||
|
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
|
||||||
|
let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||||
|
self.argyllLicense = content
|
||||||
|
} else {
|
||||||
|
self.argyllLicense = """
|
||||||
|
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
|
||||||
|
|
||||||
|
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
|
||||||
|
A copy of the license should be present at Resources/Argyll/License.txt.
|
||||||
|
|
||||||
|
See: https://git.i3omb.com/gronod/argyllcms/releases
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Title bar
|
||||||
|
HStack {
|
||||||
|
Text("Licenses & Attribution")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("closeLicenseBtn")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Theme.panel.opacity(0.9))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
// Section 1: ICCery License
|
||||||
|
licenseSection(
|
||||||
|
title: "ICCery License",
|
||||||
|
content: icceryLicense,
|
||||||
|
identifier: "icceryLicenseSection"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Section 2: ArgyllCMS License
|
||||||
|
licenseSection(
|
||||||
|
title: "ArgyllCMS License (AGPLv3)",
|
||||||
|
content: argyllLicense,
|
||||||
|
identifier: "argyllLicenseSection"
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Section 3: Attribution & Links
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Attribution & Links")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("attributionHeader")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
|
||||||
|
destination: URL(string: "https://www.argyllcms.com/")!)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("argyllUpstreamLink")
|
||||||
|
|
||||||
|
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
|
||||||
|
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("argyllForkLink")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.accessibilityIdentifier("agplIsolationNote")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("attributionSection")
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 600, minHeight: 500)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("licenseWindow")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func licenseSection(title: String, content: String, identifier: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier(identifier + "Header")
|
||||||
|
|
||||||
|
Text(content)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityIdentifier(identifier + "Content")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// `#saveMediaRecipeDialog` — capture the current printer + paper +
|
||||||
|
/// ink + `.cal` bound to the selected preset (issue #146). Clones
|
||||||
|
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
|
||||||
|
struct SaveMediaRecipeDialog: View {
|
||||||
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var printerCaption: String {
|
||||||
|
let queue = printSession.selectedPrinter
|
||||||
|
guard !queue.isEmpty else { return "None" }
|
||||||
|
let display = printSession.printers
|
||||||
|
.first { $0.name == queue }?.displayName ?? queue
|
||||||
|
return "\(display) (\(queue))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureRow(
|
||||||
|
_ label: String, value: String, identifier: String
|
||||||
|
) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.accessibilityIdentifier(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saveDisabled: Bool {
|
||||||
|
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| media.captureColourSpaceMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||||
|
// children (#146); Group blocks are layout-transparent, so field
|
||||||
|
// order and every docs/21 id are unchanged.
|
||||||
|
private var fields: some View {
|
||||||
|
Group {
|
||||||
|
TextField("Name", text: $media.saveMediaName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaName")
|
||||||
|
TextField("Notes (optional)", text: $media.saveMediaNotes)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaNotes")
|
||||||
|
TextField("Paper", text: $media.saveMediaPaper)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaPaper")
|
||||||
|
TextField("Ink set", text: $media.saveMediaInk)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("saveMediaInk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readOnlyRows: some View {
|
||||||
|
Group {
|
||||||
|
captureRow("Printer", value: printerCaption,
|
||||||
|
identifier: "saveMediaPrinter")
|
||||||
|
captureRow("Preset",
|
||||||
|
value: workflow.selectedPreset?.name ?? "No preset",
|
||||||
|
identifier: "saveMediaPreset")
|
||||||
|
captureRow("Colour space",
|
||||||
|
value: workflow.colourSpace.rawValue.uppercased(),
|
||||||
|
identifier: "saveMediaColourSpace")
|
||||||
|
captureRow("Calibration",
|
||||||
|
value: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? "None" : workflow.profile.calibrationFile,
|
||||||
|
identifier: "saveMediaCal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
fields
|
||||||
|
readOnlyRows
|
||||||
|
Toggle("Apply calibration to profile",
|
||||||
|
isOn: $media.saveMediaApplyCal)
|
||||||
|
.disabled(!media.calApplyable)
|
||||||
|
.accessibilityIdentifier("saveMediaApplyCal")
|
||||||
|
|
||||||
|
if media.captureColourSpaceMismatch {
|
||||||
|
Text("Colour space does not match the selected preset.")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
if let error = media.saveMediaError {
|
||||||
|
Text(error).font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { workflow.showingSaveMedia = false }
|
||||||
|
.accessibilityIdentifier("btnCloseSaveMediaDialog")
|
||||||
|
Button("Save") {
|
||||||
|
Task {
|
||||||
|
if await media.captureFromSession() {
|
||||||
|
workflow.showingSaveMedia = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(saveDisabled)
|
||||||
|
.accessibilityIdentifier("btnConfirmSaveMedia")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 380)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("saveMediaRecipeDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#manageMediaDialog` — list, apply, delete, capture (issue #146).
|
||||||
|
/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`.
|
||||||
|
struct ManageMediaDialog: View {
|
||||||
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@State private var selection: String?
|
||||||
|
@State private var pendingDelete: MediaRecipe?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetCaption(for recipe: MediaRecipe) -> String {
|
||||||
|
workflow.presets.first { $0.id == recipe.presetID }?.name
|
||||||
|
?? "Missing preset"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calCaption(for recipe: MediaRecipe) -> String {
|
||||||
|
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
|
||||||
|
return "Stale"
|
||||||
|
}
|
||||||
|
if let days = media.calAgeDays[recipe.id] {
|
||||||
|
return "Cal \(days)d"
|
||||||
|
}
|
||||||
|
return "No cal"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAndDismiss(_ recipe: MediaRecipe) {
|
||||||
|
Task {
|
||||||
|
if await media.apply(recipe) {
|
||||||
|
workflow.showingManageMedia = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
|
||||||
|
!workflow.presets.contains { $0.id == recipe.presetID }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calStale(_ recipe: MediaRecipe) -> Bool {
|
||||||
|
media.staleReasons[recipe.id]?.contains(.calibration) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(_ recipe: MediaRecipe) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(recipe.name).foregroundStyle(Theme.text)
|
||||||
|
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(presetCaption(for: recipe))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
|
||||||
|
Text(calCaption(for: recipe))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Apply") { applyAndDismiss(recipe) }
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
pendingDelete = recipe
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("mediaRow-\(recipe.id)")
|
||||||
|
.tag(recipe.id)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
List(selection: $selection) {
|
||||||
|
if media.recipes.isEmpty {
|
||||||
|
Text("No media recipes yet. Capture the current printer, paper and preset.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("mediaLibraryEmpty")
|
||||||
|
}
|
||||||
|
ForEach(media.recipes) { recipe in
|
||||||
|
row(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("mediaLibraryList")
|
||||||
|
.frame(minHeight: 260)
|
||||||
|
|
||||||
|
if let notice = media.manageApplyNotice {
|
||||||
|
Text(notice)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.accessibilityIdentifier("manageMediaNotice")
|
||||||
|
.accessibilityValue(notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Apply selected") {
|
||||||
|
if let id = selection,
|
||||||
|
let recipe = media.recipes.first(where: { $0.id == id }) {
|
||||||
|
applyAndDismiss(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(selection == nil)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryApply")
|
||||||
|
Button("Capture current…") {
|
||||||
|
media.captureAfterManageDismiss = true
|
||||||
|
workflow.showingManageMedia = false
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { workflow.showingManageMedia = false }
|
||||||
|
.accessibilityIdentifier("btnCloseManageMediaDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 640)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("manageMediaDialog")
|
||||||
|
.onAppear {
|
||||||
|
media.reload()
|
||||||
|
media.refreshStaleness()
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Delete media recipe?",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingDelete != nil },
|
||||||
|
set: { if !$0 { pendingDelete = nil } }
|
||||||
|
),
|
||||||
|
presenting: pendingDelete
|
||||||
|
) { recipe in
|
||||||
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
media.delete(recipe)
|
||||||
|
pendingDelete = nil
|
||||||
|
}
|
||||||
|
} message: { recipe in
|
||||||
|
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Media recipe library: capture / apply / staleness for issue #146.
|
||||||
|
///
|
||||||
|
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
|
||||||
|
/// `ProfilingPreset`. Applying a recipe goes through the existing
|
||||||
|
/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper
|
||||||
|
/// and ink are library metadata only; they are never written to the
|
||||||
|
/// targen label fields.
|
||||||
|
@MainActor
|
||||||
|
final class MediaLibraryViewModel: ObservableObject {
|
||||||
|
|
||||||
|
/// Why a recipe row is flagged stale.
|
||||||
|
struct StaleReason: OptionSet {
|
||||||
|
let rawValue: Int
|
||||||
|
static let calibration = StaleReason(rawValue: 1 << 0)
|
||||||
|
static let printer = StaleReason(rawValue: 1 << 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let store: MediaLibraryStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@Published var recipes: [MediaRecipe] = []
|
||||||
|
/// Sidebar picker selection; `"none"` = no media recipe. Written
|
||||||
|
/// only on a successful apply so a failed apply snaps back.
|
||||||
|
@Published var selectedRecipeID = "none"
|
||||||
|
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
|
||||||
|
@Published var staleReasons: [String: StaleReason] = [:]
|
||||||
|
/// `recipe.id` → whole days since the bound `.cal` was created.
|
||||||
|
@Published var calAgeDays: [String: Int] = [:]
|
||||||
|
|
||||||
|
// Save-sheet state (mirrors savePresetName/savePresetDesc).
|
||||||
|
@Published var saveMediaName = ""
|
||||||
|
@Published var saveMediaNotes = ""
|
||||||
|
@Published var saveMediaPaper = ""
|
||||||
|
@Published var saveMediaInk = ""
|
||||||
|
@Published var saveMediaApplyCal = false
|
||||||
|
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||||
|
@Published var saveMediaError: String?
|
||||||
|
/// Last failed Apply while Manage is open. The window banner sits
|
||||||
|
/// behind the sheet on Monterey, so the dialog shows this too (#170).
|
||||||
|
@Published var manageApplyNotice: String?
|
||||||
|
|
||||||
|
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||||
|
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||||
|
/// present-while-dismissing race.
|
||||||
|
var captureAfterManageDismiss = false
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
self.store = environment.mediaStore
|
||||||
|
|
||||||
|
reload()
|
||||||
|
refreshStaleness()
|
||||||
|
// The library needs queues enumerated at launch so Capture can
|
||||||
|
// enable and the not-installed badge is computable; Stage 2 only
|
||||||
|
// enumerates when a printtarg manifest exists.
|
||||||
|
if workflow.print.printers.isEmpty {
|
||||||
|
workflow.print.refreshPrinters()
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default
|
||||||
|
.publisher(for: SettingsStore.settingsDidChange)
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.print.$printers
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.print.$selectedPrinter
|
||||||
|
.sink { [weak self] _ in self?.refreshStaleness() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { cancellables.removeAll() }
|
||||||
|
|
||||||
|
// MARK: - Load / corrupt
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
Task { await reloadAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAsync() async {
|
||||||
|
do {
|
||||||
|
recipes = try await store.load()
|
||||||
|
} catch {
|
||||||
|
// Corrupt-file policy: keep the file, keep the cache,
|
||||||
|
// persistent warning; the picker falls back to "none".
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Media library is unreadable — the existing file was kept.",
|
||||||
|
kind: .warning,
|
||||||
|
autoHideAfter: nil
|
||||||
|
)
|
||||||
|
if recipes.isEmpty { selectedRecipeID = "none" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection / apply
|
||||||
|
|
||||||
|
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
|
||||||
|
/// without resetting any Stage 1/2/4 field — it is not "reset to
|
||||||
|
/// factory".
|
||||||
|
func selectRecipe(_ id: String) {
|
||||||
|
if id == "none" {
|
||||||
|
selectedRecipeID = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
|
||||||
|
Task { _ = await apply(recipe) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single apply path — sidebar picker, manage-row Apply, and
|
||||||
|
/// the manage footer all funnel here.
|
||||||
|
///
|
||||||
|
/// Returns `false` when any bound resource is unresolved (missing
|
||||||
|
/// preset, colour-space mismatch, queue absent, missing/unparseable
|
||||||
|
/// `.cal`) so the manage sheet stays open and the picker reverts.
|
||||||
|
/// A `CAL_`-blocked calibration counts as applied (`true` — success
|
||||||
|
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||||
|
@discardableResult
|
||||||
|
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||||
|
manageApplyNotice = nil
|
||||||
|
guard let r = try? recipe.validated() else {
|
||||||
|
return failApply("Media recipe is invalid — not applied.", kind: .error)
|
||||||
|
}
|
||||||
|
guard let preset = environment.presetStore.all()
|
||||||
|
.first(where: { $0.id == r.presetID })
|
||||||
|
else {
|
||||||
|
return failApply(
|
||||||
|
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||||
|
return failApply(
|
||||||
|
"Recipe colour space does not match its preset — not applied.",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||||
|
workflow.applyPreset(preset)
|
||||||
|
// Literal per issue: displayName, not the queue id.
|
||||||
|
workflow.wizard.printerName = r.printerDisplayName
|
||||||
|
|
||||||
|
// Queue: enumerate fresh via the session's serialized path —
|
||||||
|
// listPrinters uses fixed process ids, so an overlapping
|
||||||
|
// enumeration would throw duplicateID. An empty result is a
|
||||||
|
// valid list.
|
||||||
|
if let queues = await workflow.print.enumeratePrinters() {
|
||||||
|
if queues.contains(where: { $0.name == r.printerID }) {
|
||||||
|
workflow.print.selectedPrinter = r.printerID
|
||||||
|
await workflow.print.reloadSelectedCapabilities()
|
||||||
|
} else {
|
||||||
|
return failApply(
|
||||||
|
"Printer \(r.printerDisplayName) is not installed.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return failApply(
|
||||||
|
"Could not enumerate printers — queue left unchanged.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calibration — the recipe is authoritative and runs after
|
||||||
|
// applyPreset so the preset's own cal fields don't win.
|
||||||
|
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
|
let calStem = URL(fileURLWithPath: calPath)
|
||||||
|
.deletingPathExtension().lastPathComponent
|
||||||
|
let blocked = r.applyCalibration && !calPath.isEmpty
|
||||||
|
&& (CalibrationIdentity.isCalibration(calStem)
|
||||||
|
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
|
||||||
|
|
||||||
|
if blocked {
|
||||||
|
// Literal CAL_ refusal on both names (decision 1): keep the
|
||||||
|
// path for display but never let `printtarg -K` see it.
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
selectedRecipeID = r.id
|
||||||
|
refreshStaleness()
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
|
||||||
|
kind: .warning,
|
||||||
|
autoHideAfter: nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.applyCalibration && !calPath.isEmpty {
|
||||||
|
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
return failApply(
|
||||||
|
"Calibration file is missing: \(calPath)", kind: .error)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||||
|
let calStore = CalibrationStore(staleDays: staleDays)
|
||||||
|
try await calStore.load(url: URL(fileURLWithPath: calPath))
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
workflow.profile.applyCalibration = true
|
||||||
|
// Age check only — the .cal DESCRIPTOR is free text, not
|
||||||
|
// a queue id, so a name compare false-positives.
|
||||||
|
if await calStore.isStale() {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Applied \(r.name) — calibration is stale.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
return failApply(
|
||||||
|
"Could not load calibration: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
workflow.profile.applyCalibration = false
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedRecipeID = r.id
|
||||||
|
workflow.wizard.showNotice("Applied \(r.name)")
|
||||||
|
refreshStaleness()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
|
||||||
|
manageApplyNotice = text
|
||||||
|
workflow.wizard.showNotice(text, kind: kind)
|
||||||
|
refreshStaleness()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture
|
||||||
|
|
||||||
|
/// Whether the live `profile.calibrationFile` may be applied:
|
||||||
|
/// non-empty, not a `CAL_` stem, and present on disk.
|
||||||
|
var calApplyable: Bool {
|
||||||
|
let path = workflow.profile.calibrationFile
|
||||||
|
guard !path.isEmpty else { return false }
|
||||||
|
let stem = URL(fileURLWithPath: path)
|
||||||
|
.deletingPathExtension().lastPathComponent
|
||||||
|
guard !CalibrationIdentity.isCalibration(stem) else { return false }
|
||||||
|
return FileManager.default.fileExists(atPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bound preset whose colour space disagrees with the live form —
|
||||||
|
/// the sheet shows the mismatch caption and disables Save.
|
||||||
|
var captureColourSpaceMismatch: Bool {
|
||||||
|
guard let preset = workflow.selectedPreset else { return false }
|
||||||
|
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the capture sheet, prefilled from the selected recipe else
|
||||||
|
/// the most recently captured one ("last recipe … or empty").
|
||||||
|
func beginCapture() {
|
||||||
|
let source = recipes.first(where: { $0.id == selectedRecipeID })
|
||||||
|
?? recipes.last
|
||||||
|
saveMediaPaper = source?.paperName ?? ""
|
||||||
|
saveMediaInk = source?.inkSet ?? ""
|
||||||
|
saveMediaName = ""
|
||||||
|
saveMediaNotes = ""
|
||||||
|
saveMediaError = nil
|
||||||
|
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
|
||||||
|
if workflow.print.printers.isEmpty {
|
||||||
|
workflow.print.refreshPrinters()
|
||||||
|
}
|
||||||
|
workflow.showingSaveMedia = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save button — the sheet closes only on `true`.
|
||||||
|
func captureFromSession() async -> Bool {
|
||||||
|
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
|
||||||
|
saveMediaError = "Name, paper and ink are required."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = workflow.print.selectedPrinter
|
||||||
|
guard !queue.isEmpty else {
|
||||||
|
saveMediaError = "Select a printer in Stage 2 first."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset binding: the selected preset when its colour space
|
||||||
|
// matches the live form; otherwise auto-snapshot the live form
|
||||||
|
// as a custom preset (decision 2).
|
||||||
|
let presetID: String
|
||||||
|
if let bound = workflow.selectedPreset {
|
||||||
|
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
|
||||||
|
saveMediaError = "Colour space does not match the selected preset."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
presetID = bound.id
|
||||||
|
} else {
|
||||||
|
let snapshot = ProfilingPreset(
|
||||||
|
id: "custom-\(UUID().uuidString.lowercased())",
|
||||||
|
name: name,
|
||||||
|
description: "Auto-saved for media recipe",
|
||||||
|
targen: workflow.buildTargenConfig(),
|
||||||
|
printtarg: workflow.buildPrinttargConfig(),
|
||||||
|
colprof: workflow.profile.buildColprofConfig(),
|
||||||
|
calibrationFile: nil,
|
||||||
|
applyCalibration: nil
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try environment.presetStore.saveCustom(snapshot)
|
||||||
|
workflow.reloadPresets()
|
||||||
|
workflow.selectedPresetID = snapshot.id
|
||||||
|
} catch {
|
||||||
|
saveMediaError = "Could not save preset: \(error.localizedDescription)"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
presetID = snapshot.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cal path is stored verbatim; applyCalibration is forced
|
||||||
|
// off for CAL_/missing paths via calApplyable.
|
||||||
|
let calPath = workflow.profile.calibrationFile
|
||||||
|
let printer = workflow.print.printers.first { $0.name == queue }
|
||||||
|
let now = Date()
|
||||||
|
let recipe = MediaRecipe(
|
||||||
|
id: "recipe-\(UUID().uuidString.lowercased())",
|
||||||
|
name: name,
|
||||||
|
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
printerID: queue,
|
||||||
|
printerDisplayName: printer?.displayName ?? queue,
|
||||||
|
paperName: paper,
|
||||||
|
driverMediaType: workflow.print.selectedMediaType,
|
||||||
|
inkSet: ink,
|
||||||
|
colourSpace: workflow.colourSpace.rawValue,
|
||||||
|
presetID: presetID,
|
||||||
|
calibrationURL: calPath.isEmpty ? nil : calPath,
|
||||||
|
applyCalibration: saveMediaApplyCal && calApplyable,
|
||||||
|
created: now,
|
||||||
|
updated: now
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let validated = try recipe.validated()
|
||||||
|
try await store.upsert(validated)
|
||||||
|
await reloadAsync()
|
||||||
|
selectedRecipeID = validated.id
|
||||||
|
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
|
||||||
|
return true
|
||||||
|
} catch let error as MediaLibraryStore.MediaLibraryError {
|
||||||
|
saveMediaError = error.errorDescription
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
saveMediaError = "Could not save: \(error.localizedDescription)"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func delete(_ recipe: MediaRecipe) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await store.delete(id: recipe.id)
|
||||||
|
await reloadAsync()
|
||||||
|
if selectedRecipeID == recipe.id {
|
||||||
|
selectedRecipeID = "none"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not delete: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staleness
|
||||||
|
|
||||||
|
func refreshStaleness() {
|
||||||
|
Task { await refreshStalenessAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
|
||||||
|
///
|
||||||
|
/// `.printer` fires only when `printerID` is absent from a
|
||||||
|
/// **non-empty** enumerated queue list — an un-enumerated list is
|
||||||
|
/// indeterminate, not stale (decision 5). `.calibration` is a pure
|
||||||
|
/// age check (`isStale()` with no `comparedTo:`) — the `.cal`
|
||||||
|
/// DESCRIPTOR is free text, not a queue id.
|
||||||
|
func refreshStalenessAsync() async {
|
||||||
|
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||||
|
let queues = workflow.print.printers
|
||||||
|
let now = Date()
|
||||||
|
let calStore = CalibrationStore(staleDays: staleDays)
|
||||||
|
|
||||||
|
var reasons: [String: StaleReason] = [:]
|
||||||
|
var ages: [String: Int] = [:]
|
||||||
|
for r in recipes {
|
||||||
|
var flags: StaleReason = []
|
||||||
|
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
|
||||||
|
flags.insert(.printer)
|
||||||
|
}
|
||||||
|
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!raw.isEmpty,
|
||||||
|
FileManager.default.fileExists(atPath: raw),
|
||||||
|
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
|
||||||
|
let created = await calStore.data?.created {
|
||||||
|
ages[r.id] = Calendar.current
|
||||||
|
.dateComponents([.day], from: created, to: now).day ?? 0
|
||||||
|
if await calStore.isStale() {
|
||||||
|
flags.insert(.calibration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flags.isEmpty { reasons[r.id] = flags }
|
||||||
|
}
|
||||||
|
staleReasons = reasons
|
||||||
|
calAgeDays = ages
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manage sheet flow
|
||||||
|
|
||||||
|
/// Called from the manage sheet's `onDismiss`. A deferred capture
|
||||||
|
/// request opens the save sheet only now, after the manage sheet has
|
||||||
|
/// fully dismissed.
|
||||||
|
func manageDismissed() {
|
||||||
|
if captureAfterManageDismiss {
|
||||||
|
captureAfterManageDismiss = false
|
||||||
|
beginCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var printerEnumTask: Task<[Printer]?, Never>?
|
||||||
|
|
||||||
func refreshPrinters() {
|
func refreshPrinters() {
|
||||||
let cups = environment.cupsService
|
Task { @MainActor in _ = await enumeratePrinters() }
|
||||||
Task { @MainActor in
|
}
|
||||||
|
|
||||||
|
/// Serialized queue enumeration — `listPrinters` uses fixed process
|
||||||
|
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
|
||||||
|
/// callers coalesce onto the in-flight task (#146).
|
||||||
|
@discardableResult
|
||||||
|
func enumeratePrinters() async -> [Printer]? {
|
||||||
|
if let pending = printerEnumTask { return await pending.value }
|
||||||
|
let task = Task { @MainActor [weak self] () -> [Printer]? in
|
||||||
|
guard let self else { return nil }
|
||||||
do {
|
do {
|
||||||
let list = try await cups.listPrinters()
|
let list = try await self.environment.cupsService.listPrinters()
|
||||||
printers = list
|
self.printers = list
|
||||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
if !list.contains(where: { $0.name == self.selectedPrinter }) {
|
||||||
selectedPrinter = list.first { $0.isDefault }?.name
|
self.selectedPrinter = list.first { $0.isDefault }?.name
|
||||||
?? list.first?.name ?? ""
|
?? list.first?.name ?? ""
|
||||||
}
|
}
|
||||||
await reloadSelectedCapabilities()
|
await self.reloadSelectedCapabilities()
|
||||||
|
return list
|
||||||
} catch {
|
} catch {
|
||||||
printNotice = Notice(
|
self.printNotice = Notice(
|
||||||
kind: .error,
|
kind: .error,
|
||||||
text: "Could not list printers: \(error.localizedDescription)"
|
text: "Could not list printers: \(error.localizedDescription)"
|
||||||
)
|
)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
printerEnumTask = task
|
||||||
|
let result = await task.value
|
||||||
|
printerEnumTask = nil
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadSelectedCapabilities() async {
|
func reloadSelectedCapabilities() async {
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Project file session (issue #149): `.icceryproj` open/save/new/close,
|
||||||
|
/// recents, the dirty flag, and the window title.
|
||||||
|
///
|
||||||
|
/// The project is an **index** — disk artefacts still own the stepper
|
||||||
|
/// (R18). Open writes `WizardState` through the normal setters and ends
|
||||||
|
/// in the same probe `windowDidBecomeKey` uses; it never auto-runs
|
||||||
|
/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk
|
||||||
|
/// does not back.
|
||||||
|
@MainActor
|
||||||
|
final class ProjectSession: ObservableObject {
|
||||||
|
|
||||||
|
/// What to do once the dirty alert resolves.
|
||||||
|
enum PendingAction {
|
||||||
|
case new
|
||||||
|
case open(URL)
|
||||||
|
case close
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live session snapshot, compared against the bound project for
|
||||||
|
/// the dirty flag (title `•`, `btnProjectSave`).
|
||||||
|
private struct Snapshot: Equatable {
|
||||||
|
var basename: String
|
||||||
|
var cwd: String
|
||||||
|
var printerID: String?
|
||||||
|
var presetID: String?
|
||||||
|
var mediaRecipeID: String?
|
||||||
|
var calibrationURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
private let recentsStore: RecentProjectsStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Bound project file location; `nil` = no project.
|
||||||
|
@Published var projectURL: URL?
|
||||||
|
/// Last saved/opened project payload.
|
||||||
|
@Published var project: ICCeryProject?
|
||||||
|
/// Open Recent entries, newest first, missing files pruned.
|
||||||
|
@Published var recents: [RecentProjectEntry] = []
|
||||||
|
/// JSON claimed a finished profile but disk stops earlier —
|
||||||
|
/// `projectChipStale` + info banner (R18).
|
||||||
|
@Published var diskBehindNotes = false
|
||||||
|
/// `ICCery` / `ICCery — {name}` / `ICCery — {name} •`.
|
||||||
|
@Published var windowTitle = "ICCery"
|
||||||
|
/// Live session fields differ from the bound project — computed
|
||||||
|
/// fresh so decision paths (`requestNew`/`requestClose`) never see
|
||||||
|
/// a stale willSet value.
|
||||||
|
var isDirty: Bool {
|
||||||
|
guard let project else { return false }
|
||||||
|
return liveSnapshot() != snapshot(of: project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow state for RootView.
|
||||||
|
@Published var showingNewAlert = false
|
||||||
|
@Published var showingDirtyAlert = false
|
||||||
|
@Published var showingRelocateSheet = false
|
||||||
|
|
||||||
|
private var pendingAction: PendingAction?
|
||||||
|
/// Loaded project whose `cwd` is missing — relocate sheet payload.
|
||||||
|
@Published private(set) var pendingRelocate:
|
||||||
|
(project: ICCeryProject, url: URL)?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
self.recentsStore = environment.recentProjectsStore
|
||||||
|
refreshRecents()
|
||||||
|
|
||||||
|
// Dirty / title derive from live fields — observe each source;
|
||||||
|
// child VMs are not tracked through the parent's
|
||||||
|
// objectWillChange (R22-style weak sinks). `@Published` fires on
|
||||||
|
// willSet, so the recompute is deferred one main turn to read
|
||||||
|
// post-set values.
|
||||||
|
let wizard = workflow.wizard
|
||||||
|
let printSession = workflow.print
|
||||||
|
let media = workflow.media
|
||||||
|
let profile = workflow.profile
|
||||||
|
wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { cancellables.removeAll() }
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var isBound: Bool { project != nil }
|
||||||
|
|
||||||
|
/// A chartread / spotread / colprof child is live — project changes
|
||||||
|
/// wait for the instrument run to finish.
|
||||||
|
var childSessionLive: Bool {
|
||||||
|
workflow.measurement.isChartreadRunning
|
||||||
|
|| workflow.spotRead.isRunning
|
||||||
|
|| workflow.profile.isColprofRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basename eligible for persistence. A live `CAL_` stem resolves
|
||||||
|
/// only through the persisted original — never by stripping, so a
|
||||||
|
/// bare `CAL_` (Force-Quit anomaly, empty persisted original) is
|
||||||
|
/// refused rather than trusted (R11).
|
||||||
|
var resolvedBasename: String? {
|
||||||
|
let live = workflow.wizard.basename
|
||||||
|
guard CalibrationIdentity.isCalibration(live) else {
|
||||||
|
return live.isEmpty ? nil : live
|
||||||
|
}
|
||||||
|
let original = workflow.wizard.calibrationOriginalBasename
|
||||||
|
return original.isEmpty ? nil : original
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌘S / `btnProjectSave` enable rule. A `CAL_` live basename stays
|
||||||
|
/// enabled so the refusal can show its banner.
|
||||||
|
var canSave: Bool {
|
||||||
|
isBound && !workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectSaveAs` — no binding required.
|
||||||
|
var canSaveAs: Bool {
|
||||||
|
!workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectReport` — needs a real basename and cwd on disk.
|
||||||
|
var canReport: Bool {
|
||||||
|
!workflow.wizard.basename.isEmpty
|
||||||
|
&& workflow.wizard.workingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func liveSnapshot() -> Snapshot {
|
||||||
|
Snapshot(
|
||||||
|
basename: resolvedBasename ?? "",
|
||||||
|
cwd: workflow.wizard.workingDirectory?.path ?? "",
|
||||||
|
printerID: workflow.print.selectedPrinter.isEmpty
|
||||||
|
? nil : workflow.print.selectedPrinter,
|
||||||
|
presetID: workflow.selectedPresetID == "none"
|
||||||
|
? nil : workflow.selectedPresetID,
|
||||||
|
mediaRecipeID: workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : workflow.media.selectedRecipeID,
|
||||||
|
calibrationURL: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : workflow.profile.calibrationFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshot(of project: ICCeryProject) -> Snapshot {
|
||||||
|
Snapshot(
|
||||||
|
basename: project.basename,
|
||||||
|
cwd: project.cwd,
|
||||||
|
printerID: project.printerID,
|
||||||
|
presetID: project.presetID,
|
||||||
|
mediaRecipeID: project.mediaRecipeID,
|
||||||
|
calibrationURL: project.calibrationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes the window title from live fields.
|
||||||
|
func recomputeDerived() {
|
||||||
|
if let project {
|
||||||
|
windowTitle = "ICCery — \(project.name)" + (isDirty ? " •" : "")
|
||||||
|
} else {
|
||||||
|
windowTitle = "ICCery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defer the recompute one main turn — the Combine sinks fire on
|
||||||
|
/// willSet, before the changed property holds its new value.
|
||||||
|
private func scheduleRecompute() {
|
||||||
|
Task { @MainActor [weak self] in self?.recomputeDerived() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - New
|
||||||
|
|
||||||
|
/// ⌘N / `menuProjectNew`. Dirty sessions detour through the dirty
|
||||||
|
/// alert first; a live child gets a banner instead of the alert.
|
||||||
|
func requestNew() {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before starting a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .new
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showingNewAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectNewConfirm` — unbinds and clears the basename. Empty
|
||||||
|
/// is the legal "no target yet" state (#60); artefacts and
|
||||||
|
/// `media_library.json` are never deleted (R18).
|
||||||
|
func confirmNew() {
|
||||||
|
showingNewAlert = false
|
||||||
|
projectURL = nil
|
||||||
|
project = nil
|
||||||
|
pendingRelocate = nil
|
||||||
|
diskBehindNotes = false
|
||||||
|
workflow.wizard.basename = ""
|
||||||
|
workflow.targetBasename = ""
|
||||||
|
workflow.media.selectedRecipeID = "none"
|
||||||
|
workflow.wizard.sessionMode = .profile
|
||||||
|
// Re-probe — an empty basename locks stages 2–5.
|
||||||
|
workflow.wizard.windowDidBecomeKey()
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open
|
||||||
|
|
||||||
|
/// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj`
|
||||||
|
/// only). Cancel is a no-op.
|
||||||
|
func requestOpen() {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before opening a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectOpenURL
|
||||||
|
: fileDialogs.selectProjectFile()
|
||||||
|
guard let url else { return }
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .open(url)
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open from the recents submenu — same path, no open panel. A
|
||||||
|
/// missing file is dropped with a banner; no relocate is offered.
|
||||||
|
func openRecent(_ entry: RecentProjectEntry) {
|
||||||
|
guard !childSessionLive else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish the instrument session before opening a project.",
|
||||||
|
kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: entry.path)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
Task { try? await recentsStore.remove(path: entry.path) }
|
||||||
|
refreshRecents()
|
||||||
|
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .open(url)
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared open path — validates, routes to relocate when `cwd` is
|
||||||
|
/// gone, then applies. Failures leave live state untouched.
|
||||||
|
func open(_ url: URL) {
|
||||||
|
guard let loaded = loadProject(url) else { return }
|
||||||
|
apply(loaded, from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Awaitable open for tests — identical to `open` but completes
|
||||||
|
/// after apply finishes.
|
||||||
|
func openAsync(_ url: URL) async {
|
||||||
|
guard let loaded = loadProject(url) else { return }
|
||||||
|
await applyAsync(loaded, from: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode + validate + cwd-existence check. Returns the project
|
||||||
|
/// ready to apply, or nil after presenting an error banner / the
|
||||||
|
/// relocate sheet.
|
||||||
|
private func loadProject(_ url: URL) -> ICCeryProject? {
|
||||||
|
let loaded: ICCeryProject
|
||||||
|
do {
|
||||||
|
loaded = try ICCeryProject.load(from: url)
|
||||||
|
} catch let error as ICCeryProject.ValidationError {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
error.errorDescription ?? "Could not open project.",
|
||||||
|
kind: .error)
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not open project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard FileManager.default.fileExists(
|
||||||
|
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
|
||||||
|
else {
|
||||||
|
pendingRelocate = (loaded, url)
|
||||||
|
showingRelocateSheet = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync entry — the apply half runs in a Task so `media.apply`
|
||||||
|
/// (async) can run inside it.
|
||||||
|
private func apply(_ project: ICCeryProject, from url: URL) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.applyAsync(project, from: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply order (issue #149):
|
||||||
|
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
|
||||||
|
/// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else
|
||||||
|
/// `presetID` → existing preset apply. Missing recipe is not
|
||||||
|
/// fatal — open still succeeds.
|
||||||
|
/// 3. Re-probe via the existing window-focus path (#151).
|
||||||
|
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
|
||||||
|
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
|
||||||
|
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
|
||||||
|
|
||||||
|
workflow.wizard.setTarget(
|
||||||
|
basename: project.basename, workingDirectory: cwdURL)
|
||||||
|
workflow.targetBasename = project.basename
|
||||||
|
workflow.targetDirectory = cwdURL
|
||||||
|
workflow.wizard.printerName = project.printerDisplayName
|
||||||
|
?? project.printerID
|
||||||
|
workflow.wizard.profileBasename = project.profileBasename
|
||||||
|
workflow.wizard.sessionMode = .profile
|
||||||
|
if let queue = project.printerID,
|
||||||
|
workflow.print.printers.contains(where: { $0.name == queue }) {
|
||||||
|
workflow.print.selectedPrinter = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
var appliedRecipe = false
|
||||||
|
if let recipeID = project.mediaRecipeID {
|
||||||
|
if workflow.media.recipes.isEmpty {
|
||||||
|
await workflow.media.reloadAsync()
|
||||||
|
}
|
||||||
|
if let recipe = workflow.media.recipes
|
||||||
|
.first(where: { $0.id == recipeID }) {
|
||||||
|
appliedRecipe = await workflow.media.apply(recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !appliedRecipe, let presetID = project.presetID,
|
||||||
|
let preset = workflow.presets
|
||||||
|
.first(where: { $0.id == presetID }) {
|
||||||
|
workflow.applyPreset(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk owns the stepper — same probe window focus uses.
|
||||||
|
workflow.wizard.windowDidBecomeKey()
|
||||||
|
|
||||||
|
// JSON-vs-disk mismatch: notes say the profile is done but the
|
||||||
|
// artefacts stop earlier — index drift, not gating truth.
|
||||||
|
let artefacts = workflow.wizard.artefacts
|
||||||
|
if project.lastVerification != nil && !artefacts.stage4Complete {
|
||||||
|
let already = diskBehindNotes && projectURL == url
|
||||||
|
diskBehindNotes = true
|
||||||
|
if !already {
|
||||||
|
let last = artefacts.stage3Complete ? ".ti3"
|
||||||
|
: artefacts.stage2Complete ? ".ti2"
|
||||||
|
: artefacts.stage1Complete ? ".ti1" : nil
|
||||||
|
let detail = last.map { "artefacts on disk stop at \($0)." }
|
||||||
|
?? "no artefacts found on disk."
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Project notes say profile done; \(detail)",
|
||||||
|
kind: .info)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diskBehindNotes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.project = project
|
||||||
|
projectURL = url
|
||||||
|
pushRecent(url: url, name: project.name)
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relocate
|
||||||
|
|
||||||
|
/// `btnProjectRelocate` — pick a new cwd, rewrite the project file
|
||||||
|
/// atomically, then continue Apply.
|
||||||
|
func chooseRelocateFolder() {
|
||||||
|
guard let pending = pendingRelocate else { return }
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectRelocateURL
|
||||||
|
: fileDialogs.selectDirectory()
|
||||||
|
guard let url else { return }
|
||||||
|
var rewritten = pending.project
|
||||||
|
rewritten.cwd = url.path
|
||||||
|
rewritten.updated = Date()
|
||||||
|
do {
|
||||||
|
try rewritten.save(to: pending.url)
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not update project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRelocate = nil
|
||||||
|
showingRelocateSheet = false
|
||||||
|
apply(rewritten, from: pending.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectRelocateCancel` — aborts the open; live session
|
||||||
|
/// unchanged.
|
||||||
|
func cancelRelocate() {
|
||||||
|
pendingRelocate = nil
|
||||||
|
showingRelocateSheet = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save / Save As
|
||||||
|
|
||||||
|
/// ⌘S / `btnProjectSave` — writes the live session into the bound
|
||||||
|
/// URL. A live `CAL_` stem refuses unless the persisted original
|
||||||
|
/// resolves; `CAL_` is never persisted (R11).
|
||||||
|
func saveProject() {
|
||||||
|
Task { @MainActor in _ = await saveProjectAsync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveProjectAsync() async -> Bool {
|
||||||
|
guard let url = projectURL, project != nil else { return false }
|
||||||
|
guard let basename = resolvedBasename else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await write(to: url, basename: basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then
|
||||||
|
/// binds the chosen URL and pushes recents.
|
||||||
|
func saveProjectAs() {
|
||||||
|
guard let basename = resolvedBasename else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let cwd = workflow.wizard.workingDirectory else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.projectSaveURL
|
||||||
|
: fileDialogs.selectProjectSavePath(
|
||||||
|
basename: basename, startingAt: cwd)
|
||||||
|
guard let url else { return }
|
||||||
|
Task { @MainActor in _ = await write(to: url, basename: basename) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refuseUnsavable() {
|
||||||
|
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Finish or exit calibration before saving a project.",
|
||||||
|
kind: .warning)
|
||||||
|
} else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Set a target basename and working folder before saving a project.",
|
||||||
|
kind: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the project payload from live session fields and writes
|
||||||
|
/// it atomically. On success binds `url`, refreshes recents, and
|
||||||
|
/// clears the stale-chip flag. Failure → error banner, bound URL
|
||||||
|
/// unchanged.
|
||||||
|
@discardableResult
|
||||||
|
private func write(to url: URL, basename: String) async -> Bool {
|
||||||
|
guard let cwd = workflow.wizard.workingDirectory,
|
||||||
|
!cwd.path.isEmpty else {
|
||||||
|
refuseUnsavable()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let stem = workflow.wizard.profileBasename ?? basename
|
||||||
|
let snapshot = await lastVerificationSnapshot(stem: stem)
|
||||||
|
?? project?.lastVerification
|
||||||
|
let queue = workflow.print.selectedPrinter
|
||||||
|
let display = workflow.print.printers
|
||||||
|
.first { $0.name == queue }?.displayName
|
||||||
|
?? workflow.wizard.printerName
|
||||||
|
let existing = project
|
||||||
|
let name = (existing?.name.isEmpty == false)
|
||||||
|
? existing!.name : basename
|
||||||
|
let updated = ICCeryProject(
|
||||||
|
name: name,
|
||||||
|
notes: existing?.notes ?? "",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd.path,
|
||||||
|
profileBasename: workflow.wizard.profileBasename,
|
||||||
|
printerID: queue.isEmpty ? nil : queue,
|
||||||
|
printerDisplayName: display,
|
||||||
|
mediaRecipeID: workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : workflow.media.selectedRecipeID,
|
||||||
|
presetID: workflow.selectedPresetID == "none"
|
||||||
|
? nil : workflow.selectedPresetID,
|
||||||
|
calibrationURL: workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : workflow.profile.calibrationFile,
|
||||||
|
lastVerification: snapshot,
|
||||||
|
updated: Date())
|
||||||
|
do {
|
||||||
|
try updated.save(to: url)
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Could not save project: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
project = updated
|
||||||
|
projectURL = url
|
||||||
|
diskBehindNotes = false
|
||||||
|
pushRecent(url: url, name: updated.name)
|
||||||
|
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
|
||||||
|
recomputeDerived()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last `VerificationHistoryStore` record for this profile stem,
|
||||||
|
/// if any — notes only.
|
||||||
|
private func lastVerificationSnapshot(
|
||||||
|
stem: String
|
||||||
|
) async -> VerificationSnapshot? {
|
||||||
|
guard let records = try? await environment.historyStore.load()
|
||||||
|
else { return nil }
|
||||||
|
guard let record = records.last(where: {
|
||||||
|
$0.profileName == stem || $0.profileName == workflow.wizard.basename
|
||||||
|
}) else { return nil }
|
||||||
|
return VerificationSnapshot(record: record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Close
|
||||||
|
|
||||||
|
/// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay.
|
||||||
|
func requestClose() {
|
||||||
|
guard isBound else { return }
|
||||||
|
if isDirty {
|
||||||
|
pendingAction = .close
|
||||||
|
showingDirtyAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeProject() {
|
||||||
|
projectURL = nil
|
||||||
|
project = nil
|
||||||
|
diskBehindNotes = false
|
||||||
|
recomputeDerived()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dirty alert
|
||||||
|
|
||||||
|
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
|
||||||
|
/// to the pending action only when the write succeeded.
|
||||||
|
func resolveDirty(save: Bool) {
|
||||||
|
showingDirtyAlert = false
|
||||||
|
guard let action = pendingAction else { return }
|
||||||
|
if save {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if await self.saveProjectAsync() {
|
||||||
|
self.pendingAction = nil
|
||||||
|
self.proceed(with: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingAction = nil
|
||||||
|
proceed(with: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectDirtyCancel` — abandons the pending action.
|
||||||
|
func cancelDirty() {
|
||||||
|
pendingAction = nil
|
||||||
|
showingDirtyAlert = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proceed(with action: PendingAction) {
|
||||||
|
switch action {
|
||||||
|
case .new:
|
||||||
|
confirmNew()
|
||||||
|
case .open(let url):
|
||||||
|
open(url)
|
||||||
|
case .close:
|
||||||
|
closeProject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report
|
||||||
|
|
||||||
|
/// `menuProjectReport` — writes `{cwd}/{basename}-report.md`
|
||||||
|
/// atomically (generated; overwrites). No picker.
|
||||||
|
func saveReport() {
|
||||||
|
guard canReport, let cwd = workflow.wizard.workingDirectory else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let basename = workflow.wizard.basename
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let stem = self.workflow.wizard.profileBasename ?? basename
|
||||||
|
let snapshot = await self.lastVerificationSnapshot(stem: stem)
|
||||||
|
?? self.project?.lastVerification
|
||||||
|
let queue = self.workflow.print.selectedPrinter
|
||||||
|
let display = self.workflow.print.printers
|
||||||
|
.first { $0.name == queue }?.displayName
|
||||||
|
?? self.workflow.wizard.printerName
|
||||||
|
let recipeName = self.workflow.media.recipes
|
||||||
|
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
|
||||||
|
let payload = ICCeryProject(
|
||||||
|
name: self.project?.name.isEmpty == false
|
||||||
|
? self.project!.name : basename,
|
||||||
|
notes: self.project?.notes ?? "",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd.path,
|
||||||
|
profileBasename: self.workflow.wizard.profileBasename,
|
||||||
|
printerID: queue.isEmpty ? nil : queue,
|
||||||
|
printerDisplayName: display,
|
||||||
|
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
|
||||||
|
? nil : self.workflow.media.selectedRecipeID,
|
||||||
|
presetID: self.workflow.selectedPresetID == "none"
|
||||||
|
? nil : self.workflow.selectedPresetID,
|
||||||
|
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
|
||||||
|
? nil : self.workflow.profile.calibrationFile,
|
||||||
|
lastVerification: snapshot,
|
||||||
|
updated: Date())
|
||||||
|
do {
|
||||||
|
let written = try ProjectReport.write(
|
||||||
|
project: payload,
|
||||||
|
recipeName: recipeName,
|
||||||
|
artefacts: self.workflow.wizard.artefacts)
|
||||||
|
self.workflow.wizard.showNotice(
|
||||||
|
"Wrote \(written.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.workflow.wizard.showNotice(
|
||||||
|
"Report failed: \(error.localizedDescription)",
|
||||||
|
kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recents / reveal
|
||||||
|
|
||||||
|
/// Rebuilds the recents list; entries whose file is gone are
|
||||||
|
/// dropped here (submenu build), not at launch. Corrupt → `[]`,
|
||||||
|
/// file kept (R12).
|
||||||
|
func refreshRecents() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
self.recents = try await self.recentsStore.pruneMissing()
|
||||||
|
} catch {
|
||||||
|
self.recents = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushRecent(url: URL, name: String) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await self.recentsStore.add(url: url, name: name)
|
||||||
|
self.refreshRecents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `menuProjectRecentsClear` — wipes `recent_projects.json` only;
|
||||||
|
/// `.icceryproj` files are never deleted.
|
||||||
|
func clearRecents() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await self?.recentsStore.clear()
|
||||||
|
self?.recents = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnProjectReveal` — reveals the bound **project file** in
|
||||||
|
/// Finder, not the cwd.
|
||||||
|
func revealInFinder() {
|
||||||
|
guard let projectURL else { return }
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// File-menu commands for the project file (issue #149). Content of the
|
||||||
|
/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` — split out so the
|
||||||
|
/// app body stays small (type-checker budget). Menu ids are distinct
|
||||||
|
/// from the sidebar chip ids (`menuProject*` vs `btnProject*`).
|
||||||
|
struct ProjectCommands: View {
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button("New Project") { project.requestNew() }
|
||||||
|
.keyboardShortcut("n")
|
||||||
|
.accessibilityIdentifier("menuProjectNew")
|
||||||
|
Button("Open Project…") { project.requestOpen() }
|
||||||
|
.keyboardShortcut("o")
|
||||||
|
.accessibilityIdentifier("menuProjectOpen")
|
||||||
|
Menu("Open Recent") {
|
||||||
|
ForEach(project.recents) { entry in
|
||||||
|
// Names render via Text only (#114); never the raw path.
|
||||||
|
Button(entry.name) { project.openRecent(entry) }
|
||||||
|
.accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Clear Menu") { project.clearRecents() }
|
||||||
|
.accessibilityIdentifier("menuProjectRecentsClear")
|
||||||
|
}
|
||||||
|
.disabled(project.recents.isEmpty)
|
||||||
|
.accessibilityIdentifier("menuProjectRecents")
|
||||||
|
Divider()
|
||||||
|
Button("Save Project") { project.saveProject() }
|
||||||
|
.keyboardShortcut("s")
|
||||||
|
.disabled(!project.canSave)
|
||||||
|
.accessibilityIdentifier("menuProjectSave")
|
||||||
|
Button("Save Project As…") { project.saveProjectAs() }
|
||||||
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||||
|
.disabled(!project.canSaveAs)
|
||||||
|
.accessibilityIdentifier("menuProjectSaveAs")
|
||||||
|
Button("Save Report…") { project.saveReport() }
|
||||||
|
.disabled(!project.canReport)
|
||||||
|
.accessibilityIdentifier("menuProjectReport")
|
||||||
|
Divider()
|
||||||
|
Button("Close Project") { project.requestClose() }
|
||||||
|
.disabled(!project.isBound)
|
||||||
|
.accessibilityIdentifier("menuProjectClose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact project footer at the bottom of the 270 pt sidebar (issue
|
||||||
|
/// #149) — never a fourth row of large buttons (R10).
|
||||||
|
struct ProjectChip: View {
|
||||||
|
@ObservedObject var project: ProjectSession
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let bound = project.project {
|
||||||
|
Text(bound.name)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
.accessibilityIdentifier("projectChipName")
|
||||||
|
Text(URL(fileURLWithPath: bound.cwd).lastPathComponent)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
// The raw path is a tooltip — never the window title.
|
||||||
|
.help(bound.cwd)
|
||||||
|
.accessibilityIdentifier("projectChipPath")
|
||||||
|
if project.diskBehindNotes {
|
||||||
|
Text("Disk behind project notes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("projectChipStale")
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Show in Finder") { project.revealInFinder() }
|
||||||
|
.accessibilityIdentifier("btnProjectReveal")
|
||||||
|
Button("Save") { project.saveProject() }
|
||||||
|
.disabled(!project.canSave)
|
||||||
|
.accessibilityIdentifier("btnProjectSave")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("No project")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("projectChipName")
|
||||||
|
Spacer()
|
||||||
|
Button("Open…") { project.requestOpen() }
|
||||||
|
.controlSize(.small)
|
||||||
|
.accessibilityIdentifier("btnProjectOpen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.fill(Theme.panel)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.stroke(Theme.border)
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("projectChip")
|
||||||
|
.helpOverlay(
|
||||||
|
"A project remembers printer, preset and folder. The stepper still follows files on disk.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `projectRelocateSheet` — shown when an opened project's `cwd` no
|
||||||
|
/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the
|
||||||
|
/// project file atomically, then continues Apply; Cancel aborts the
|
||||||
|
/// open with live state untouched.
|
||||||
|
struct ProjectRelocateSheet: View {
|
||||||
|
@ObservedObject var project: ProjectSession
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Missing project folder")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Text(
|
||||||
|
"The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder."
|
||||||
|
)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { project.cancelRelocate() }
|
||||||
|
.accessibilityIdentifier("btnProjectRelocateCancel")
|
||||||
|
Button("Choose Folder…") { project.chooseRelocateFolder() }
|
||||||
|
.accessibilityIdentifier("btnProjectRelocate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 420)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("projectRelocateSheet")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ struct RootView: View {
|
|||||||
/// Observed directly: nested ObservableObjects are not tracked
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
/// through the parent's `objectWillChange`.
|
/// through the parent's `objectWillChange`.
|
||||||
@ObservedObject private var model: WizardViewModel
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingAllHelp = false
|
@State private var showingAllHelp = false
|
||||||
@@ -16,6 +17,7 @@ struct RootView: View {
|
|||||||
init(workflow: TargetWorkflowViewModel) {
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -37,6 +39,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
WizardStageContent(model: model, workflow: workflow)
|
WizardStageContent(model: model, workflow: workflow)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
@@ -56,6 +59,25 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||||
ManagePresetsDialog(workflow: workflow)
|
ManagePresetsDialog(workflow: workflow)
|
||||||
}
|
}
|
||||||
|
// Media library sheets live on RootView, never inside the
|
||||||
|
// 270 pt sidebar column (#146).
|
||||||
|
.sheet(isPresented: $workflow.showingSaveMedia) {
|
||||||
|
SaveMediaRecipeDialog(workflow: workflow)
|
||||||
|
}
|
||||||
|
.sheet(
|
||||||
|
isPresented: $workflow.showingManageMedia,
|
||||||
|
onDismiss: { workflow.media.manageDismissed() }
|
||||||
|
) {
|
||||||
|
ManageMediaDialog(workflow: workflow)
|
||||||
|
}
|
||||||
|
// Spot-read console sheet (issue #148). Dismiss runs the same
|
||||||
|
// `q\n` + ~500 ms + kill path as the sheet's Stop button.
|
||||||
|
.sheet(
|
||||||
|
isPresented: $workflow.showingSpotRead,
|
||||||
|
onDismiss: { workflow.spotRead.sheetClosed() }
|
||||||
|
) {
|
||||||
|
SpotReadView(model: workflow.spotRead)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingAbout) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView { showingAbout = false }
|
AboutView { showingAbout = false }
|
||||||
}
|
}
|
||||||
@@ -63,7 +85,41 @@ struct RootView: View {
|
|||||||
get: { workflow.wizard.showingGamutViewer },
|
get: { workflow.wizard.showingGamutViewer },
|
||||||
set: { workflow.wizard.showingGamutViewer = $0 }
|
set: { workflow.wizard.showingGamutViewer = $0 }
|
||||||
)) {
|
)) {
|
||||||
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
|
GamutView(
|
||||||
|
environment: workflow.environment,
|
||||||
|
profileGamURL: workflow.wizard.gamutProfileURL,
|
||||||
|
showingAllHelp: $showingAllHelp)
|
||||||
|
}
|
||||||
|
// Project file chrome (issue #149): window title, New confirm
|
||||||
|
// alert, dirty alert, relocate sheet. Panels never appear from
|
||||||
|
// a View — UITestHooks inject fixture paths instead.
|
||||||
|
.onReceive(project.$windowTitle) { title in
|
||||||
|
for window in NSApp.windows where !(window is NSPanel) {
|
||||||
|
window.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
.accessibilityIdentifier("btnProjectNewCancel")
|
||||||
|
Button("Start") { project.confirmNew() }
|
||||||
|
.accessibilityIdentifier("btnProjectNewConfirm")
|
||||||
|
} message: {
|
||||||
|
Text("The working folder and targets on disk are not deleted.")
|
||||||
|
.accessibilityIdentifier("projectNewAlert")
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Save the current project first?",
|
||||||
|
isPresented: $project.showingDirtyAlert
|
||||||
|
) {
|
||||||
|
Button("Save") { project.resolveDirty(save: true) }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtySave")
|
||||||
|
Button("Don't Save") { project.resolveDirty(save: false) }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtyDiscard")
|
||||||
|
Button("Cancel", role: .cancel) { project.cancelDirty() }
|
||||||
|
.accessibilityIdentifier("btnProjectDirtyCancel")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $project.showingRelocateSheet) {
|
||||||
|
ProjectRelocateSheet(project: project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ struct SettingsView: View {
|
|||||||
Text($0.label).tag($0.code)
|
Text($0.label).tag($0.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Verification") {
|
Section("Verification") {
|
||||||
HStack {
|
TextField(
|
||||||
Text("Good ΔE ≤")
|
"Good ΔE ≤",
|
||||||
TextField(
|
value: $model.settings.deltaEGoodMax,
|
||||||
"2.0",
|
format: .number
|
||||||
value: $model.settings.deltaEGoodMax,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsDeltaEGood")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
TextField(
|
||||||
Text("Warning ΔE ≤")
|
"Warning ΔE ≤",
|
||||||
TextField(
|
value: $model.settings.deltaEWarningMax,
|
||||||
"5.0",
|
format: .number
|
||||||
value: $model.settings.deltaEWarningMax,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsDeltaEWarning")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
|
||||||
}
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
ForEach(model.validationErrors, id: \.self) { error in
|
ForEach(model.validationErrors, id: \.self) { error in
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -93,16 +95,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Calibration") {
|
Section("Calibration") {
|
||||||
HStack {
|
TextField(
|
||||||
Text("Stale after")
|
"Stale after (days)",
|
||||||
TextField(
|
value: $model.settings.calibrationStaleDays,
|
||||||
"30",
|
format: .number
|
||||||
value: $model.settings.calibrationStaleDays,
|
)
|
||||||
format: .number
|
.accessibilityIdentifier("settingsCalStaleDays")
|
||||||
)
|
|
||||||
.frame(width: 60)
|
|
||||||
Text("days")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Profile install") {
|
Section("Profile install") {
|
||||||
@@ -143,6 +141,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 45)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ struct SidebarView: View {
|
|||||||
/// through the parent's `objectWillChange`.
|
/// through the parent's `objectWillChange`.
|
||||||
@ObservedObject private var model: WizardViewModel
|
@ObservedObject private var model: WizardViewModel
|
||||||
@ObservedObject private var profile: ProfileWorkflowViewModel
|
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||||
|
@ObservedObject private var media: MediaLibraryViewModel
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var measurement: MeasurementWorkflowViewModel
|
||||||
|
@ObservedObject private var project: ProjectSession
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
@@ -22,6 +26,10 @@ struct SidebarView: View {
|
|||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||||
|
self._media = ObservedObject(wrappedValue: workflow.media)
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
|
||||||
|
self._project = ObservedObject(wrappedValue: workflow.project)
|
||||||
self.onOpenSettings = onOpenSettings
|
self.onOpenSettings = onOpenSettings
|
||||||
self.onOpenAbout = onOpenAbout
|
self.onOpenAbout = onOpenAbout
|
||||||
self._showingAllHelp = showingAllHelp
|
self._showingAllHelp = showingAllHelp
|
||||||
@@ -29,6 +37,21 @@ struct SidebarView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
presetBlock
|
||||||
|
mediaBlock
|
||||||
|
studioButtons
|
||||||
|
stepperAndProject
|
||||||
|
}
|
||||||
|
.frame(width: Theme.Metrics.sidebarWidth)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||||
|
// children (#146); these Group blocks are layout-transparent, so
|
||||||
|
// visual order, ids and the 270 pt column are unchanged.
|
||||||
|
private var header: some View {
|
||||||
|
Group {
|
||||||
HStack {
|
HStack {
|
||||||
Image("ICCery-logo")
|
Image("ICCery-logo")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -57,7 +80,11 @@ struct SidebarView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var presetBlock: some View {
|
||||||
|
Group {
|
||||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||||
// applies the preset immediately; names render via Text only.
|
// applies the preset immediately; names render via Text only.
|
||||||
Picker("Preset", selection: Binding(
|
Picker("Preset", selection: Binding(
|
||||||
@@ -89,7 +116,65 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediaBlock: some View {
|
||||||
|
Group {
|
||||||
|
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||||
|
// applies the recipe immediately, like presets; names render
|
||||||
|
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||||
|
Picker("Media", selection: Binding(
|
||||||
|
get: { media.selectedRecipeID },
|
||||||
|
set: { media.selectRecipe($0) }
|
||||||
|
)) {
|
||||||
|
Text("No media recipe").tag("none")
|
||||||
|
ForEach(media.recipes) { recipe in
|
||||||
|
Text(recipe.name).tag(recipe.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.accessibilityIdentifier("mediaSelect")
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.helpOverlay(
|
||||||
|
"Saved printer + paper + ink + .cal bound to a preset.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
|
||||||
|
if let reasons = media.staleReasons[media.selectedRecipeID],
|
||||||
|
!reasons.isEmpty {
|
||||||
|
Text(reasons.contains(.printer)
|
||||||
|
? "Printer not installed" : "Calibration stale")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.accessibilityIdentifier("mediaRecipeStale")
|
||||||
|
.helpOverlay(
|
||||||
|
"Re-run Stage 0 or pick a different recipe.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Capture") { media.beginCapture() }
|
||||||
|
.disabled(printSession.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryCapture")
|
||||||
|
.helpOverlay(
|
||||||
|
"Select a printer in Stage 2 first",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
Button("Manage") { workflow.showingManageMedia = true }
|
||||||
|
.accessibilityIdentifier("btnMediaLibraryManage")
|
||||||
|
.helpOverlay(
|
||||||
|
"Apply or delete saved media recipes.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var studioButtons: some View {
|
||||||
|
Group {
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||||
@@ -104,9 +189,36 @@ struct SidebarView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
.helpOverlay(
|
||||||
|
"View the profile gamut in 3D against sRGB.",
|
||||||
|
showing: $showingAllHelp)
|
||||||
.accessibilityIdentifier("btnViewGamut")
|
.accessibilityIdentifier("btnViewGamut")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
|
// Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled
|
||||||
|
// only with a working folder (#59) and while no Stage 3
|
||||||
|
// chartread child is live; opening never kills
|
||||||
|
// `chartread_{basename}`.
|
||||||
|
Button(action: { workflow.showingSpotRead = true }) {
|
||||||
|
Label("Spot Read", systemImage: "eyedropper")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(model.workingDirectory == nil || measurement.isChartreadRunning)
|
||||||
|
.helpOverlay(
|
||||||
|
model.workingDirectory == nil
|
||||||
|
? "Set a working folder in Stage 1 first."
|
||||||
|
: (measurement.isChartreadRunning
|
||||||
|
? "Stop the Stage 3 chart read first."
|
||||||
|
: "Read a single patch as Lab/XYZ from the instrument."),
|
||||||
|
showing: $showingAllHelp)
|
||||||
|
.accessibilityIdentifier("btnSpotRead")
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stepperAndProject: some View {
|
||||||
|
Group {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
@@ -126,9 +238,13 @@ struct SidebarView: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Project chip (issue #149) — a compact footer in the
|
||||||
|
// spacer's bottom, under the stepper. The 270 pt column
|
||||||
|
// cannot take four more large buttons (R10).
|
||||||
|
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||||
|
.padding(8)
|
||||||
}
|
}
|
||||||
.frame(width: Theme.Metrics.sidebarWidth)
|
|
||||||
.background(Theme.panel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live
|
||||||
|
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
|
||||||
|
/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never
|
||||||
|
/// reused here.
|
||||||
|
struct SpotReadView: View {
|
||||||
|
@ObservedObject var model: SpotReadViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header
|
||||||
|
if !model.sidecarAvailable {
|
||||||
|
missingSidecar
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
instrumentCard
|
||||||
|
promptLine
|
||||||
|
transport
|
||||||
|
lastSampleCard
|
||||||
|
historySection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(width: 560, height: 640)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotReadView")
|
||||||
|
.onAppear { model.sheetOpened() }
|
||||||
|
.onDisappear { model.sheetClosed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header / missing sidecar
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("Spot Read")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
if model.isRunning {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var missingSidecar: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("spotread sidecar missing — run fetch-argyll")
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("spotSidecarMissing")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instrument card (clones Stage 3 look, own ids)
|
||||||
|
|
||||||
|
private var instrumentCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Instrument")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { model.detectInstruments() }) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!model.canDetect)
|
||||||
|
.accessibilityIdentifier("btnSpotDetectInstruments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.detectionError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("spotDetectError")
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Instrument", selection: Binding(
|
||||||
|
get: { instrumentTag },
|
||||||
|
set: { newTag in
|
||||||
|
if newTag.isEmpty {
|
||||||
|
model.selectedInstrument = .auto
|
||||||
|
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
|
||||||
|
model.selectedInstrument = .device(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Auto (first available port)").tag("")
|
||||||
|
ForEach(model.instruments) { device in
|
||||||
|
Text(device.displayName).tag("\(device.port)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.disabled(model.isRunning)
|
||||||
|
.accessibilityIdentifier("spotInstrumentSelect")
|
||||||
|
|
||||||
|
if model.defaultMissing {
|
||||||
|
Text("Saved default instrument not present")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("spotDefaultMissing")
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Also set as default instrument", isOn: Binding(
|
||||||
|
get: { model.setAsDefault },
|
||||||
|
set: { model.applyDefaultToggle($0) }
|
||||||
|
))
|
||||||
|
.accessibilityIdentifier("spotSetDefault")
|
||||||
|
|
||||||
|
if model.selectedInstrument.isXY {
|
||||||
|
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("spotXYHint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instrumentTag: String {
|
||||||
|
switch model.selectedInstrument {
|
||||||
|
case .auto:
|
||||||
|
return ""
|
||||||
|
case .device(let device):
|
||||||
|
return "\(device.port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt line
|
||||||
|
|
||||||
|
private var promptLine: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Status")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Text(model.prompt)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("spotPrompt")
|
||||||
|
}
|
||||||
|
if let error = model.lastError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("spotLastError")
|
||||||
|
.accessibilityValue(error)
|
||||||
|
}
|
||||||
|
if !model.log.isEmpty {
|
||||||
|
ProcessLogView(
|
||||||
|
lines: model.log,
|
||||||
|
minHeight: 60,
|
||||||
|
maxHeight: 100,
|
||||||
|
containerId: "spotLogContainer",
|
||||||
|
logId: "spotLog"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
private var transport: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if !model.isRunning {
|
||||||
|
Button("Start") { model.start() }
|
||||||
|
.disabled(!model.canStart)
|
||||||
|
.accessibilityIdentifier("btnSpotStart")
|
||||||
|
} else {
|
||||||
|
switch model.state {
|
||||||
|
case .calibrating:
|
||||||
|
Button("Calibrate") { model.calibrate() }
|
||||||
|
.accessibilityIdentifier("btnSpotCalibrate")
|
||||||
|
case .awaitingStrip:
|
||||||
|
Button("Read") { model.trigger() }
|
||||||
|
.accessibilityIdentifier("btnSpotTrigger")
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Button("Stop") { model.stopIfNeeded() }
|
||||||
|
.accessibilityIdentifier("btnSpotStop")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Last sample
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var lastSampleCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Last sample")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if let sample = model.displayedSample {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
let rgb = LabColorMath.labToSRGB(sample.lab)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
|
||||||
|
.accessibilityIdentifier("spotSwatch")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "L* %.1f", sample.lab.l))
|
||||||
|
.accessibilityIdentifier("spotLabL")
|
||||||
|
Text(String(format: "a* %.1f", sample.lab.a))
|
||||||
|
.accessibilityIdentifier("spotLabA")
|
||||||
|
Text(String(format: "b* %.1f", sample.lab.b))
|
||||||
|
.accessibilityIdentifier("spotLabB")
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if let xyz = sample.xyz {
|
||||||
|
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotXYZ")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
|
||||||
|
?? sample.instrumentName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotLastInstrument")
|
||||||
|
|
||||||
|
if let de = model.displayedDeltaE {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(deltaEColor)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(String(format: "ΔE %.2f", de))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotDeltaE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.isDisplayedLabImplausible {
|
||||||
|
Text("Implausible L*")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.accessibilityIdentifier("spotLabImplausible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("spotLastSample")
|
||||||
|
} else {
|
||||||
|
Text("No readings yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotLastEmpty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deltaEColor: Color {
|
||||||
|
switch model.deltaEClassification {
|
||||||
|
case .good, nil: return .green
|
||||||
|
case .warning: return .orange
|
||||||
|
case .bad: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var historySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("History")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
if model.samples.isEmpty {
|
||||||
|
Text("No history.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("spotHistoryEmpty")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
|
||||||
|
historyRow(index: index, sample: sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.accessibilityIdentifier("spotHistoryTable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
|
||||||
|
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
|
||||||
|
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
|
||||||
|
return Button(action: { model.selectFromHistory(sample) }) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(sample.timestamp, style: .time)
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
Text(String(format: "%.1f", sample.lab.l))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f", sample.lab.a))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f", sample.lab.b))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
Text(sample.instrumentName)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Copy Lab") { model.copyLab() }
|
||||||
|
.disabled(model.displayedSample == nil)
|
||||||
|
.accessibilityIdentifier("btnSpotCopyLab")
|
||||||
|
Button("Export CSV…") { model.exportCsv() }
|
||||||
|
.disabled(model.samples.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnSpotExportCsv")
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("btnCloseSpotRead")
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Spot-read console state and interaction (issue #148).
|
||||||
|
///
|
||||||
|
/// Runs the bundled `spotread` sidecar under the single-lease process id
|
||||||
|
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
|
||||||
|
/// seeds the instrument picker on sheet open — it is never written into
|
||||||
|
/// `printtarg -i` or `targen` argv (R15).
|
||||||
|
@MainActor
|
||||||
|
final class SpotReadViewModel: ObservableObject {
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Instrument card
|
||||||
|
|
||||||
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
|
@Published var isDetecting = false
|
||||||
|
@Published var detectionError: String?
|
||||||
|
/// `spotDefaultMissing` — set when `defaultInstrument` is saved but
|
||||||
|
/// no detected device matches it.
|
||||||
|
@Published var defaultMissing = false
|
||||||
|
/// `spotSetDefault` toggle state.
|
||||||
|
@Published var setAsDefault = false
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
@Published var isRunning = false
|
||||||
|
@Published var state: ChartreadState = .idle
|
||||||
|
@Published var prompt = "Press Start to open the instrument."
|
||||||
|
@Published var lastError: String?
|
||||||
|
@Published var log: [String] = []
|
||||||
|
|
||||||
|
// MARK: - Samples / history (in-memory, cap 50, newest first)
|
||||||
|
|
||||||
|
@Published private(set) var samples: [SpotReadSample] = []
|
||||||
|
@Published private(set) var displayedSample: SpotReadSample?
|
||||||
|
@Published private(set) var displayedDeltaE: Double?
|
||||||
|
|
||||||
|
private let historyLimit = 50
|
||||||
|
private var streamTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
/// Whether the bundled `spotread` sidecar resolves to an executable.
|
||||||
|
/// `BinaryResolver` only — never `$PATH`, never `chartread`.
|
||||||
|
var sidecarAvailable: Bool {
|
||||||
|
let url = environment.runner.binaryResolver.resolve("spotread")
|
||||||
|
return environment.runner.binaryResolver.exists(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
|
||||||
|
|
||||||
|
var canStart: Bool {
|
||||||
|
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
|
||||||
|
&& workflow.wizard.effectiveWorkingDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var canDetect: Bool { !isDetecting && !isRunning }
|
||||||
|
|
||||||
|
var deltaEClassification: SwatchClassification? {
|
||||||
|
guard let de = displayedDeltaE else { return nil }
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
return ColorDifference.classify(
|
||||||
|
deltaE: de,
|
||||||
|
goodMax: settings.deltaEGoodMax,
|
||||||
|
warningMax: settings.deltaEWarningMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `spotLabImplausible` — L* outside 0…100 still displays, unclamped.
|
||||||
|
var isDisplayedLabImplausible: Bool {
|
||||||
|
guard let l = displayedSample?.lab.l else { return false }
|
||||||
|
return l < 0 || l > 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet lifecycle
|
||||||
|
|
||||||
|
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
|
||||||
|
/// and runs detection once; a missing sidecar gets a wizard notice.
|
||||||
|
func sheetOpened() {
|
||||||
|
samples = []
|
||||||
|
displayedSample = nil
|
||||||
|
displayedDeltaE = nil
|
||||||
|
log = []
|
||||||
|
lastError = nil
|
||||||
|
state = .idle
|
||||||
|
prompt = "Press Start to open the instrument."
|
||||||
|
|
||||||
|
guard sidecarAvailable else {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"spotread sidecar missing — run fetch-argyll", kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detectInstruments()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing
|
||||||
|
/// the flag alone is not enough; a live child must be quit and
|
||||||
|
/// killed (R14).
|
||||||
|
func sheetClosed() {
|
||||||
|
stopIfNeeded()
|
||||||
|
samples = []
|
||||||
|
displayedSample = nil
|
||||||
|
displayedDeltaE = nil
|
||||||
|
log = []
|
||||||
|
defaultMissing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
func detectInstruments() {
|
||||||
|
guard canDetect else { return }
|
||||||
|
isDetecting = true
|
||||||
|
detectionError = nil
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// `instlist` is an exclusive lease (#116) — never spawn a
|
||||||
|
// second one; surface the busy state instead.
|
||||||
|
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
|
||||||
|
self.detectionError = "Instrument detection is already running."
|
||||||
|
self.isDetecting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let devices = try await self.environment.runner.detectInstruments()
|
||||||
|
self.instruments = devices
|
||||||
|
self.seedDefault(from: devices)
|
||||||
|
if case .device(let selected) = self.selectedInstrument,
|
||||||
|
!devices.contains(where: { $0.port == selected.port }) {
|
||||||
|
self.selectedInstrument = .auto
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.detectionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isDetecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the picker from `AppSettings.defaultInstrument`; no match →
|
||||||
|
/// `.auto` + `spotDefaultMissing`.
|
||||||
|
private func seedDefault(from devices: [InstrumentDevice]) {
|
||||||
|
guard let code = environment.settingsStore.load().defaultInstrument,
|
||||||
|
!code.isEmpty else {
|
||||||
|
defaultMissing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
|
||||||
|
selectedInstrument = .device(match)
|
||||||
|
defaultMissing = false
|
||||||
|
} else {
|
||||||
|
selectedInstrument = .auto
|
||||||
|
defaultMissing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an `instlist` device corresponds to a `printtarg -i` /
|
||||||
|
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
|
||||||
|
static func matches(code: String, device: InstrumentDevice) -> Bool {
|
||||||
|
let haystack = "\(device.name) \(device.type)".lowercased()
|
||||||
|
switch code {
|
||||||
|
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
|
||||||
|
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
|
||||||
|
case "CM": return haystack.contains("colormunki")
|
||||||
|
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
|
||||||
|
case "20": return haystack.contains("display 2")
|
||||||
|
case "22": return haystack.contains("display")
|
||||||
|
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
|
||||||
|
case "51": return haystack.contains("spyder x")
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse of `matches` — most specific codes first.
|
||||||
|
static func code(for device: InstrumentDevice) -> String? {
|
||||||
|
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
|
||||||
|
where matches(code: code, device: device) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `spotSetDefault` — writes `AppSettings.defaultInstrument` only.
|
||||||
|
/// Never touches `printtarg -i` or `targen`.
|
||||||
|
func applyDefaultToggle(_ on: Bool) {
|
||||||
|
setAsDefault = on
|
||||||
|
var settings = environment.settingsStore.load()
|
||||||
|
if on, case .device(let device) = selectedInstrument {
|
||||||
|
settings.defaultInstrument = Self.code(for: device)
|
||||||
|
} else if !on {
|
||||||
|
settings.defaultInstrument = nil
|
||||||
|
}
|
||||||
|
try? environment.settingsStore.save(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session control
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard sidecarAvailable else {
|
||||||
|
lastError = "spotread sidecar missing — run fetch-argyll"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isChartreadRunning else {
|
||||||
|
lastError = "Stop the Stage 3 chart read first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
|
||||||
|
lastError = "Set a working folder in Stage 1 first."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// `spotread` is an exclusive lease — a second Start while a
|
||||||
|
// child is live is an error, not a kill + respawn (#116).
|
||||||
|
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
|
||||||
|
self.lastError = "A spotread session is already running."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.begin(config: self.buildConfig(cwd: cwd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildConfig(cwd: URL) -> SpotReadConfig {
|
||||||
|
let port: Int?
|
||||||
|
let name: String
|
||||||
|
switch selectedInstrument {
|
||||||
|
case .auto:
|
||||||
|
port = nil
|
||||||
|
name = "Auto"
|
||||||
|
case .device(let device):
|
||||||
|
port = device.port
|
||||||
|
name = device.name
|
||||||
|
}
|
||||||
|
return SpotReadConfig(
|
||||||
|
workingDirectory: cwd,
|
||||||
|
selectedPort: selectedInstrument.chartreadPort,
|
||||||
|
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
|
||||||
|
isXY: selectedInstrument.isXY,
|
||||||
|
instrumentName: name,
|
||||||
|
instrumentPort: port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func begin(config: SpotReadConfig) {
|
||||||
|
isRunning = true
|
||||||
|
state = .idle
|
||||||
|
lastError = nil
|
||||||
|
prompt = "Waiting for a reading…"
|
||||||
|
|
||||||
|
let stream = environment.runner.runSpotread(config: config)
|
||||||
|
streamTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for await event in stream {
|
||||||
|
self.handle(event: event)
|
||||||
|
}
|
||||||
|
self.isRunning = false
|
||||||
|
self.state = .idle
|
||||||
|
if self.lastError == nil {
|
||||||
|
self.prompt = "Press Start to open the instrument."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: SpotReadEvent) {
|
||||||
|
switch event {
|
||||||
|
case .prompt(let result):
|
||||||
|
state = result.state
|
||||||
|
prompt = promptText(for: result.state)
|
||||||
|
|
||||||
|
case .sample(let sample):
|
||||||
|
let previous = samples.first
|
||||||
|
samples.insert(sample, at: 0)
|
||||||
|
if samples.count > historyLimit {
|
||||||
|
samples.removeLast()
|
||||||
|
}
|
||||||
|
displayedSample = sample
|
||||||
|
displayedDeltaE = previous.map {
|
||||||
|
ColorDifference.deltaE00($0.lab, sample.lab)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .log(let batch):
|
||||||
|
log.append(contentsOf: batch)
|
||||||
|
|
||||||
|
case .exit(let code):
|
||||||
|
if code != 0 {
|
||||||
|
lastError = "spotread exited with code \(code)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failed(let error):
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptText(for state: ChartreadState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .calibrating:
|
||||||
|
return "Place the instrument on the calibration tile, then Calibrate."
|
||||||
|
case .awaitingStrip:
|
||||||
|
return "Place on the patch, then Read."
|
||||||
|
case .reading, .promptContinue:
|
||||||
|
return "Waiting for a reading…"
|
||||||
|
case .warning:
|
||||||
|
return "Instrument warning — stop and restart if it persists."
|
||||||
|
case .error:
|
||||||
|
return "Read error — Stop, then Start again."
|
||||||
|
default:
|
||||||
|
return "Waiting for a reading…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
/// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate.
|
||||||
|
func calibrate() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotTrigger` — the Read key (`" \n"`).
|
||||||
|
func trigger() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ input: ChartreadInput) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.isRunning else { return }
|
||||||
|
try? await self.environment.runner.sendSpotreadInput(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
|
||||||
|
/// child is still live.
|
||||||
|
func stopIfNeeded() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
streamTask?.cancel()
|
||||||
|
streamTask = nil
|
||||||
|
let processManager = environment.runner.processManager
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await processManager.sendStdin(
|
||||||
|
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
await processManager.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
isRunning = false
|
||||||
|
state = .idle
|
||||||
|
prompt = "Press Start to open the instrument."
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History / export
|
||||||
|
|
||||||
|
/// Click a history row: copies that sample into the last-sample card.
|
||||||
|
/// Never re-triggers the instrument.
|
||||||
|
func selectFromHistory(_ sample: SpotReadSample) {
|
||||||
|
displayedSample = sample
|
||||||
|
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
|
||||||
|
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
|
||||||
|
} else {
|
||||||
|
displayedDeltaE = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain
|
||||||
|
/// text (`50.0 1.2 -3.4`).
|
||||||
|
func copyLab() {
|
||||||
|
guard let sample = displayedSample else { return }
|
||||||
|
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is
|
||||||
|
/// a no-op. Rows are newest-first, matching the history list.
|
||||||
|
func exportCsv() {
|
||||||
|
guard !samples.isEmpty else { return }
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.csvExportURL
|
||||||
|
: fileDialogs.selectCsvSavePath()
|
||||||
|
guard let url else { return }
|
||||||
|
|
||||||
|
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
|
||||||
|
for (index, sample) in samples.enumerated() {
|
||||||
|
let deltaE = index + 1 < samples.count
|
||||||
|
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
|
||||||
|
: ""
|
||||||
|
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try out.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
workflow.wizard.showNotice(
|
||||||
|
"Export failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func f1(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iso8601(_ date: Date) -> String {
|
||||||
|
ISO8601DateFormatter().string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func csvField(_ text: String) -> String {
|
||||||
|
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,16 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
@Published var savePresetName = ""
|
@Published var savePresetName = ""
|
||||||
@Published var savePresetDesc = ""
|
@Published var savePresetDesc = ""
|
||||||
|
|
||||||
|
// MARK: - Media library (issue #146)
|
||||||
|
|
||||||
|
@Published var showingSaveMedia = false
|
||||||
|
@Published var showingManageMedia = false
|
||||||
|
|
||||||
|
// MARK: - Spot read (issue #148)
|
||||||
|
|
||||||
|
/// `RootView` sheet binding for the spot-read console.
|
||||||
|
@Published var showingSpotRead = false
|
||||||
|
|
||||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
@Published var measurement: MeasurementWorkflowViewModel
|
@Published var measurement: MeasurementWorkflowViewModel
|
||||||
@@ -107,6 +117,13 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
@Published var calibration: CalibrationViewModel!
|
@Published var calibration: CalibrationViewModel!
|
||||||
/// Stage 2 unmanaged print session.
|
/// Stage 2 unmanaged print session.
|
||||||
@Published var print: PrintSessionViewModel!
|
@Published var print: PrintSessionViewModel!
|
||||||
|
/// Media recipe library — needs a complete `self`.
|
||||||
|
@Published var media: MediaLibraryViewModel!
|
||||||
|
/// Spot-read console — needs `wizard` / `measurement`.
|
||||||
|
@Published var spotRead: SpotReadViewModel!
|
||||||
|
/// Project file session (issue #149), created last — needs `media`
|
||||||
|
/// for recipe apply and `spotRead` for the live-child check.
|
||||||
|
@Published var project: ProjectSession!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
@@ -126,6 +143,18 @@ final class TargetWorkflowViewModel: ObservableObject {
|
|||||||
profile: self.profile,
|
profile: self.profile,
|
||||||
environment: environment
|
environment: environment
|
||||||
)
|
)
|
||||||
|
self.media = MediaLibraryViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.spotRead = SpotReadViewModel(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.project = ProjectSession(
|
||||||
|
workflow: self,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
reloadPresets()
|
reloadPresets()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// ``ApproximateLab`` sanity tests (issue #147).
|
||||||
|
///
|
||||||
|
/// These are loose sanity checks on a fixed-matrix approximation — not
|
||||||
|
/// ColorSync goldens.
|
||||||
|
final class ApproximateLabTests: XCTestCase {
|
||||||
|
|
||||||
|
func testWhiteMapsToHighLStar() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255)
|
||||||
|
XCTAssertGreaterThan(lab.l, 95)
|
||||||
|
XCTAssertEqual(lab.a, 0, accuracy: 2)
|
||||||
|
XCTAssertEqual(lab.b, 0, accuracy: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlackMapsToZeroLStar() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0)
|
||||||
|
XCTAssertEqual(lab.l, 0, accuracy: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPureRedIsChromatic() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0)
|
||||||
|
// sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only.
|
||||||
|
XCTAssertGreaterThan(lab.l, 40)
|
||||||
|
XCTAssertLessThan(lab.l, 65)
|
||||||
|
XCTAssertGreaterThan(lab.a, 60)
|
||||||
|
XCTAssertGreaterThan(lab.b, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMidGreyIsNeutral() {
|
||||||
|
let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128)
|
||||||
|
XCTAssertGreaterThan(lab.l, 45)
|
||||||
|
XCTAssertLessThan(lab.l, 65)
|
||||||
|
XCTAssertEqual(lab.a, 0, accuracy: 1)
|
||||||
|
XCTAssertEqual(lab.b, 0, accuracy: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// ``GamutGeometry`` containment and volume goldens on the bundled
|
||||||
|
/// `sRGB.gam` reference mesh (issue #147). No GPU involved.
|
||||||
|
final class GamutContainmentTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
|
||||||
|
private var bundledSRGBGamURL: URL {
|
||||||
|
let bundle = Bundle.main
|
||||||
|
let resource = bundle.resourceURL ?? bundle.bundleURL
|
||||||
|
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSRGB() throws -> GamutMesh {
|
||||||
|
try GamutMeshParser.parse(url: bundledSRGBGamURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeutralMidGreyIsInsideSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaturatedColourIsOutsideSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh),
|
||||||
|
.outside)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws {
|
||||||
|
let mesh = try loadSRGB()
|
||||||
|
let volume = GamutGeometry.volume(of: mesh)
|
||||||
|
XCTAssertTrue(volume.isFinite)
|
||||||
|
XCTAssertGreaterThan(volume, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVertexOnlyMeshReportsUnknown() {
|
||||||
|
let mesh = GamutMesh(
|
||||||
|
vertices: [
|
||||||
|
GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)),
|
||||||
|
],
|
||||||
|
faces: [])
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.unknown)
|
||||||
|
XCTAssertEqual(GamutGeometry.volume(of: mesh), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKnownCubeFixture() throws {
|
||||||
|
// Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60.
|
||||||
|
// Two triangles per face, outward winding.
|
||||||
|
let lab = { (l: Double, a: Double, b: Double) in
|
||||||
|
GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0))
|
||||||
|
}
|
||||||
|
// Corners in (a, L, b) space.
|
||||||
|
let c = [
|
||||||
|
lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom
|
||||||
|
lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top
|
||||||
|
]
|
||||||
|
let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in
|
||||||
|
[GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)]
|
||||||
|
}
|
||||||
|
var faces: [GamutTriangle] = []
|
||||||
|
faces += quad(0, 3, 2, 1) // bottom (y=40)
|
||||||
|
faces += quad(4, 5, 6, 7) // top (y=60)
|
||||||
|
faces += quad(0, 1, 5, 4) // z=-10
|
||||||
|
faces += quad(3, 7, 6, 2) // z=+10
|
||||||
|
faces += quad(1, 2, 6, 5) // x=+10
|
||||||
|
faces += quad(0, 4, 7, 3) // x=-10
|
||||||
|
let mesh = GamutMesh(vertices: c, faces: faces)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
|
||||||
|
.inside)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh),
|
||||||
|
.outside)
|
||||||
|
// 20 × 20 × 20 Lab-cube.
|
||||||
|
XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Foundation
|
||||||
|
import Metal
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #147 — `GamutViewModel` compare-slot behaviour: failed or
|
||||||
|
/// missing compare meshes are info, never fatal (#24); sRGB always stays.
|
||||||
|
@MainActor
|
||||||
|
final class GamutViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool
|
||||||
|
/// binaries, so `iccgamut` spawns deterministically fail.
|
||||||
|
private func bundledRootWithoutTools() throws -> URL {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)")
|
||||||
|
let gamutDir = root.appendingPathComponent("reference_gamuts")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: gamutDir, withIntermediateDirectories: true)
|
||||||
|
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
|
||||||
|
try FileManager.default.copyItem(
|
||||||
|
at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"),
|
||||||
|
to: gamutDir.appendingPathComponent("sRGB.gam"))
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeViewModel(root: URL? = nil) throws -> GamutViewModel {
|
||||||
|
let env = try TestAppEnvironment.make(bundledArgyllRoot: root)
|
||||||
|
return GamutViewModel(environment: env.environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialLoadHasSRGBAndFacesStatus() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #147 — `viewerUnavailable` is decided before any `SCNView` is
|
||||||
|
/// mounted: it must exactly mirror Metal presence on this host.
|
||||||
|
func testViewerUnavailableMirrorsMetalAvailability() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
XCTAssertEqual(
|
||||||
|
vm.viewerUnavailable,
|
||||||
|
MTLCreateSystemDefaultDevice() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
|
||||||
|
await vm.loadCompareGam(
|
||||||
|
url: URL(fileURLWithPath: "/nonexistent/compare.gam"))
|
||||||
|
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
|
||||||
|
XCTAssertNotNil(vm.noticeText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws {
|
||||||
|
// bundledRootWithoutTools has no macos-universal/iccgamut.
|
||||||
|
let vm = try makeViewModel(root: bundledRootWithoutTools())
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
|
||||||
|
let profile = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc")
|
||||||
|
try Data("MOCK_ICC".utf8).write(to: profile)
|
||||||
|
defer { try? FileManager.default.removeItem(at: profile) }
|
||||||
|
|
||||||
|
await vm.loadCompareProfile(url: profile)
|
||||||
|
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
|
||||||
|
XCTAssertNotNil(vm.noticeText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThirdProfileReplacesCompareSlot() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
|
||||||
|
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
|
||||||
|
let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true)
|
||||||
|
let first = dir.appendingPathComponent("first.gam")
|
||||||
|
let second = dir.appendingPathComponent("second.gam")
|
||||||
|
try FileManager.default.copyItem(at: srgb, to: first)
|
||||||
|
try FileManager.default.copyItem(at: srgb, to: second)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
await vm.loadCompareGam(url: first)
|
||||||
|
XCTAssertNil(vm.noticeText)
|
||||||
|
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first")
|
||||||
|
|
||||||
|
await vm.loadCompareGam(url: second)
|
||||||
|
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second")
|
||||||
|
XCTAssertEqual(
|
||||||
|
vm.layers.filter { $0.role == .profileB }.count, 1,
|
||||||
|
"compare slot holds one profile")
|
||||||
|
XCTAssertNotNil(vm.noticeText)
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveCompareLeavesSRGB() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
|
||||||
|
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
|
||||||
|
await vm.loadCompareGam(
|
||||||
|
url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"))
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID))
|
||||||
|
|
||||||
|
vm.removeCompare()
|
||||||
|
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
|
||||||
|
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInspectLabRunsContainmentPerLayer() async throws {
|
||||||
|
let vm = try makeViewModel()
|
||||||
|
await vm.awaitInitialLoad()
|
||||||
|
|
||||||
|
vm.labEntryL = "50"
|
||||||
|
vm.labEntryA = "0"
|
||||||
|
vm.labEntryB = "0"
|
||||||
|
XCTAssertTrue(vm.canInspectLab)
|
||||||
|
vm.inspectEnteredLab()
|
||||||
|
|
||||||
|
let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID }
|
||||||
|
XCTAssertEqual(srgb?.containment, .inside)
|
||||||
|
XCTAssertTrue(vm.inspectIsApproximate)
|
||||||
|
XCTAssertNotNil(vm.inspectSwatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #149 — `.icceryproj` schema: snake_case keys, strict
|
||||||
|
/// `schema_version == 1`, and save-time refusal for empty/illegal
|
||||||
|
/// basename and empty/unsafe cwd (#59/#60/R11).
|
||||||
|
final class ICCeryProjectTests: XCTestCase {
|
||||||
|
|
||||||
|
private func tempDir() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-proj-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProject(
|
||||||
|
basename: String = "run-1",
|
||||||
|
cwd: String = "/tmp"
|
||||||
|
) -> ICCeryProject {
|
||||||
|
ICCeryProject(
|
||||||
|
name: "Run One",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwd,
|
||||||
|
profileBasename: "run-1",
|
||||||
|
printerID: "Mock_Queue",
|
||||||
|
printerDisplayName: "Mock Queue",
|
||||||
|
mediaRecipeID: "recipe-1",
|
||||||
|
presetID: "preset-std-rgb",
|
||||||
|
calibrationURL: "/tmp/r.cal",
|
||||||
|
lastVerification: VerificationSnapshot(
|
||||||
|
date: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
avgDE00: 0.8, maxDE00: 2.1,
|
||||||
|
status: "excellent", profileFilename: "run-1.icc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Round trip / keys
|
||||||
|
|
||||||
|
func testSnakeCaseRoundTrip() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
|
||||||
|
let project = makeProject()
|
||||||
|
try project.save(to: url)
|
||||||
|
let loaded = try ICCeryProject.load(from: url)
|
||||||
|
|
||||||
|
XCTAssertEqual(loaded.schemaVersion, 1)
|
||||||
|
XCTAssertEqual(loaded.basename, "run-1")
|
||||||
|
XCTAssertEqual(loaded.cwd, "/tmp")
|
||||||
|
XCTAssertEqual(loaded.profileBasename, "run-1")
|
||||||
|
XCTAssertEqual(loaded.printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(loaded.mediaRecipeID, "recipe-1")
|
||||||
|
XCTAssertEqual(loaded.presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal")
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8)
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1)
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.status, "excellent")
|
||||||
|
XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc")
|
||||||
|
|
||||||
|
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
for key in [
|
||||||
|
"\"schema_version\"", "\"profile_basename\"",
|
||||||
|
"\"printer_id\"", "\"printer_display_name\"",
|
||||||
|
"\"media_recipe_id\"", "\"preset_id\"",
|
||||||
|
"\"calibration_url\"", "\"last_verification\"",
|
||||||
|
"\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"",
|
||||||
|
] {
|
||||||
|
XCTAssertTrue(raw.contains(key), "missing key \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalFieldsDecodeWhenAbsent() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("min.icceryproj")
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "Minimal",
|
||||||
|
"basename": "abc",
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"updated": "2026-09-13T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try json.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let loaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(loaded.basename, "abc")
|
||||||
|
XCTAssertNil(loaded.mediaRecipeID)
|
||||||
|
XCTAssertNil(loaded.presetID)
|
||||||
|
XCTAssertNil(loaded.lastVerification)
|
||||||
|
XCTAssertNil(loaded.calibrationURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema gate
|
||||||
|
|
||||||
|
func testSchemaVersion2Throws() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("v2.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 2, "name": "x", "basename": "abc",
|
||||||
|
"cwd": "/tmp", "future_field": [1, 2]}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.unsupportedSchema(2)
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected unsupportedSchema, got \(error)")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
error.localizedDescription,
|
||||||
|
"This project file is not schema 1.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSchemaVersionThrows() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("noversion.icceryproj")
|
||||||
|
try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}"
|
||||||
|
.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
func testEmptyBasenameRefusesSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(basename: "").save(to: url)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? ICCeryProject.ValidationError, .emptyBasename)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIllegalBasenameRefusesSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
for bad in ["a/b", "a\\b", "..", "x..y"] {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(basename: bad).save(
|
||||||
|
to: dir.appendingPathComponent("p.icceryproj"))
|
||||||
|
) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.invalidBasename
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected invalidBasename, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyAndRelativeCwdRefuseSave() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("p.icceryproj")
|
||||||
|
XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? ICCeryProject.ValidationError, .emptyCwd)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try makeProject(cwd: "relative/dir").save(to: url)
|
||||||
|
) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.unsafeCwd = error else {
|
||||||
|
return XCTFail("expected unsafeCwd, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIllegalBasenameRefusesOpen() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let url = dir.appendingPathComponent("bad.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 1, "basename": "a/b", "cwd": "/tmp"}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
|
||||||
|
guard case ICCeryProject.ValidationError.invalidBasename
|
||||||
|
= error else {
|
||||||
|
return XCTFail("expected invalidBasename, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyNameFallsBackToBasename() throws {
|
||||||
|
let project = try ICCeryProject(
|
||||||
|
name: "", basename: "stem", cwd: "/tmp").validated()
|
||||||
|
XCTAssertEqual(project.name, "stem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingFileThrowsOnOpen() {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gone-\(UUID().uuidString).icceryproj")
|
||||||
|
XCTAssertThrowsError(try ICCeryProject.load(from: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaLibraryStore` persistence contract.
|
||||||
|
final class MediaLibraryStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private var url: URL!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
url = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recipe(id: String, name: String = "R") -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: id, name: name, printerID: "q",
|
||||||
|
colourSpace: "rgb", presetID: "preset-std-rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTrip() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
let r = recipe(id: "recipe-1", name: "Epson Rag")
|
||||||
|
try await store.upsert(r)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertEqual(loaded, [r])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptFileThrowsAndPreservesBytes() async throws {
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let before = try Data(contentsOf: url)
|
||||||
|
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.load()
|
||||||
|
}
|
||||||
|
// upsert must also propagate — a corrupt file is never wiped.
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.upsert(recipe(id: "x"))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDelete() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
try await store.upsert(recipe(id: "a"))
|
||||||
|
try await store.upsert(recipe(id: "b"))
|
||||||
|
|
||||||
|
let removed = try await store.delete(id: "a")
|
||||||
|
XCTAssertTrue(removed)
|
||||||
|
let remaining = try await store.load().map(\.id)
|
||||||
|
XCTAssertEqual(remaining, ["b"])
|
||||||
|
|
||||||
|
let again = try await store.delete(id: "a")
|
||||||
|
XCTAssertFalse(again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapacityReached() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url, capacity: 2)
|
||||||
|
try await store.upsert(recipe(id: "1"))
|
||||||
|
try await store.upsert(recipe(id: "2"))
|
||||||
|
await assertAsyncThrows(
|
||||||
|
expectedType: MediaLibraryStore.MediaLibraryError.self
|
||||||
|
) {
|
||||||
|
try await store.upsert(recipe(id: "3"))
|
||||||
|
} errorHandler: {
|
||||||
|
XCTAssertEqual($0, .capacityReached(2))
|
||||||
|
}
|
||||||
|
let stored = try await store.load()
|
||||||
|
XCTAssertEqual(stored.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpsertPreservesCreatedBumpsUpdated() async throws {
|
||||||
|
let store = MediaLibraryStore(url: url)
|
||||||
|
var r = recipe(id: "recipe-1")
|
||||||
|
r.created = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
r.updated = Date(timeIntervalSince1970: 1_000_000)
|
||||||
|
try await store.upsert(r)
|
||||||
|
|
||||||
|
var edit = r
|
||||||
|
edit.name = "Renamed"
|
||||||
|
edit.updated = Date(timeIntervalSince1970: 2_000_000)
|
||||||
|
try await store.upsert(edit)
|
||||||
|
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertEqual(loaded.count, 1)
|
||||||
|
XCTAssertEqual(loaded[0].name, "Renamed")
|
||||||
|
XCTAssertEqual(loaded[0].created, r.created)
|
||||||
|
XCTAssertGreaterThan(loaded[0].updated, r.updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness
|
||||||
|
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
|
||||||
|
@MainActor
|
||||||
|
final class MediaLibraryViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
// CTI3 fixture mirrored from CalibrationStoreTests.
|
||||||
|
private static let sampleCal = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Test printer"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "OUTPUT"
|
||||||
|
MAX_TAC "300"
|
||||||
|
NUMBER_OF_FIELDS 5
|
||||||
|
NUMBER_OF_SETS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID INPUT_VALUE R G B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 0 0 0 0
|
||||||
|
2 128 64 64 64
|
||||||
|
3 255 255 255 255
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Same fixture plus an old CREATED keyword so the age check fires.
|
||||||
|
private static let staleCal = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Test printer"
|
||||||
|
CREATED "2020-01-01T00:00:00Z"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "OUTPUT"
|
||||||
|
NUMBER_OF_FIELDS 5
|
||||||
|
NUMBER_OF_SETS 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID INPUT_VALUE R G B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 0 0 0 0
|
||||||
|
2 128 64 64 64
|
||||||
|
3 255 255 255 255
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var media: MediaLibraryViewModel!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
env = try TestAppEnvironment.make()
|
||||||
|
try installMockCups()
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
media = workflow.media
|
||||||
|
await media.reloadAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
env?.cleanup()
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
media = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
|
||||||
|
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
|
||||||
|
/// `Mock_Queue` (default) and `Other_Queue`.
|
||||||
|
private func installMockCups() throws {
|
||||||
|
let bin = env.root.appendingPathComponent("cups-bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: bin, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let lpstat = """
|
||||||
|
#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
|
||||||
|
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
|
||||||
|
-d) printf 'system default destination: Mock_Queue\\n' ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
list=0
|
||||||
|
queue=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-l) list=1 ;;
|
||||||
|
-p) ;;
|
||||||
|
*) queue="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$list" = "1" ]; then
|
||||||
|
printf 'PageSize/Media Size: *A4 Letter\\n'
|
||||||
|
printf 'MediaType/Media Type: *Stationery Glossy\\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
|
||||||
|
let url = bin.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeCal(
|
||||||
|
_ contents: String = MediaLibraryViewModelTests.sampleCal,
|
||||||
|
named name: String = "recipe.cal"
|
||||||
|
) throws -> String {
|
||||||
|
let url = env.root.appendingPathComponent(name)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRecipe(
|
||||||
|
id: String = "recipe-t1",
|
||||||
|
printerID: String = "Mock_Queue",
|
||||||
|
colourSpace: String = "rgb",
|
||||||
|
presetID: String = "preset-std-rgb",
|
||||||
|
calibrationURL: String? = nil,
|
||||||
|
applyCalibration: Bool = false
|
||||||
|
) -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: id,
|
||||||
|
name: "Test Recipe",
|
||||||
|
printerID: printerID,
|
||||||
|
printerDisplayName: "Mock Queue Display",
|
||||||
|
paperName: "Rag",
|
||||||
|
inkSet: "PK",
|
||||||
|
colourSpace: colourSpace,
|
||||||
|
presetID: presetID,
|
||||||
|
calibrationURL: calibrationURL,
|
||||||
|
applyCalibration: applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seed(_ recipe: MediaRecipe) async throws {
|
||||||
|
try await env.environment.mediaStore.upsert(recipe)
|
||||||
|
await media.reloadAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply
|
||||||
|
|
||||||
|
func testApplyHappyPath() async throws {
|
||||||
|
let calPath = try writeCal()
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
|
||||||
|
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
|
||||||
|
XCTAssertTrue(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(
|
||||||
|
workflow.buildPrinttargConfig().calibrationFile, calPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingPresetRefuses() async throws {
|
||||||
|
let r = makeRecipe(presetID: "preset-nonexistent")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "none")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("no longer exists") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyColourSpaceMismatchRefuses() async throws {
|
||||||
|
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("colour space") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingCalFileFails() async throws {
|
||||||
|
let missing = env.root.appendingPathComponent("gone.cal").path
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: missing, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, missing)
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyCalPrefixedCalCannotArmK() async throws {
|
||||||
|
let calPath = try writeCal(named: "CAL_target.cal")
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
// Success with warning — the refusal is permanent, re-clicking
|
||||||
|
// cannot unstick it (decision 6).
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
|
||||||
|
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, r.id)
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyLiveCalBasenameBlocks() async throws {
|
||||||
|
let calPath = try writeCal()
|
||||||
|
let r = makeRecipe(
|
||||||
|
calibrationURL: calPath, applyCalibration: true)
|
||||||
|
try await seed(r)
|
||||||
|
workflow.wizard.basename = "CAL_live"
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertTrue(applied)
|
||||||
|
XCTAssertFalse(workflow.profile.applyCalibration)
|
||||||
|
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyMissingQueueLeavesQueueUntouched() async throws {
|
||||||
|
workflow.print.selectedPrinter = "Other_Queue"
|
||||||
|
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
let applied = await media.apply(r)
|
||||||
|
|
||||||
|
XCTAssertFalse(applied)
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("is not installed") == true)
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capture
|
||||||
|
|
||||||
|
func testCaptureCopiesPrinterAndPreset() async throws {
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
media.saveMediaName = "My Recipe"
|
||||||
|
media.saveMediaPaper = "Rag"
|
||||||
|
media.saveMediaInk = "PK"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored.count, 1)
|
||||||
|
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
|
||||||
|
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(stored[0].colourSpace, "rgb")
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureNoPresetAutoSnapshots() async throws {
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "none"
|
||||||
|
media.saveMediaName = "Snap"
|
||||||
|
media.saveMediaPaper = "Rag"
|
||||||
|
media.saveMediaInk = "MK"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored.count, 1)
|
||||||
|
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
env.environment.presetStore.customs()
|
||||||
|
.contains { $0.id == stored[0].presetID })
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureRequiresPrinter() async {
|
||||||
|
workflow.print.selectedPrinter = ""
|
||||||
|
media.saveMediaName = "n"
|
||||||
|
media.saveMediaPaper = "p"
|
||||||
|
media.saveMediaInk = "i"
|
||||||
|
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
XCTAssertNotNil(media.saveMediaError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureForcesOffCalToggleForCalFile() async throws {
|
||||||
|
let calPath = try writeCal(named: "CAL_target.cal")
|
||||||
|
workflow.profile.calibrationFile = calPath
|
||||||
|
workflow.profile.applyCalibration = true
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
media.saveMediaName = "n"
|
||||||
|
media.saveMediaPaper = "p"
|
||||||
|
media.saveMediaInk = "i"
|
||||||
|
media.saveMediaApplyCal = true // forced off by calApplyable
|
||||||
|
|
||||||
|
XCTAssertFalse(media.calApplyable)
|
||||||
|
let saved = await media.captureFromSession()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
|
||||||
|
XCTAssertFalse(stored[0].applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staleness
|
||||||
|
|
||||||
|
func testStaleCalFlagged() async throws {
|
||||||
|
let calPath = try writeCal(
|
||||||
|
MediaLibraryViewModelTests.staleCal, named: "old.cal")
|
||||||
|
let r = makeRecipe(calibrationURL: calPath)
|
||||||
|
try await seed(r)
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Queue")]
|
||||||
|
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
media.staleReasons[r.id]?.contains(.calibration) == true)
|
||||||
|
XCTAssertNotNil(media.calAgeDays[r.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
|
||||||
|
let r = makeRecipe(printerID: "No_Such_Queue")
|
||||||
|
try await seed(r)
|
||||||
|
|
||||||
|
// Un-enumerated list is indeterminate → no flag.
|
||||||
|
workflow.print.printers = []
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertNil(media.staleReasons[r.id])
|
||||||
|
|
||||||
|
// Absent from a non-empty list → .printer.
|
||||||
|
workflow.print.printers = [Printer(name: "Other_Queue")]
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertTrue(
|
||||||
|
media.staleReasons[r.id]?.contains(.printer) == true)
|
||||||
|
|
||||||
|
// Present but unselected → no flag.
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Other_Queue"
|
||||||
|
await media.refreshStalenessAsync()
|
||||||
|
XCTAssertNil(media.staleReasons[r.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
func testDeleteResetsSelection() async throws {
|
||||||
|
let r = makeRecipe()
|
||||||
|
try await seed(r)
|
||||||
|
media.selectedRecipeID = r.id
|
||||||
|
|
||||||
|
media.delete(r)
|
||||||
|
try await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(media.selectedRecipeID, "none")
|
||||||
|
let stored = await env.environment.mediaStore.all()
|
||||||
|
XCTAssertTrue(stored.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corrupt library
|
||||||
|
|
||||||
|
func testCorruptLibraryKeepsFileAndWarns() async throws {
|
||||||
|
// Fresh environment so the store's `loaded` flag is still false.
|
||||||
|
let env2 = try TestAppEnvironment.make()
|
||||||
|
defer { env2.cleanup() }
|
||||||
|
try "garbage".write(
|
||||||
|
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
|
||||||
|
let workflow2 = TargetWorkflowViewModel(
|
||||||
|
environment: env2.environment)
|
||||||
|
await workflow2.media.reloadAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(workflow2.media.recipes.isEmpty)
|
||||||
|
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
|
||||||
|
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
|
||||||
|
"garbage".data(using: .utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #146 — `MediaRecipe` Codable + validation contract.
|
||||||
|
final class MediaRecipeTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeRecipe() -> MediaRecipe {
|
||||||
|
MediaRecipe(
|
||||||
|
id: "recipe-abc",
|
||||||
|
name: "Epson Rag",
|
||||||
|
notes: "notes",
|
||||||
|
printerID: "epson_p900",
|
||||||
|
printerDisplayName: "Epson SureColor P900",
|
||||||
|
paperName: "Rag Photographique",
|
||||||
|
driverMediaType: "PhotographicGlossy",
|
||||||
|
inkSet: "PK",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
presetID: "preset-std-rgb",
|
||||||
|
calibrationURL: "/tmp/prof.cal",
|
||||||
|
applyCalibration: true,
|
||||||
|
created: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
updated: Date(timeIntervalSince1970: 1_700_000_100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoundTripSnakeCase() throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
let data = try encoder.encode(makeRecipe())
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"printer_id", "printer_display_name", "paper_name",
|
||||||
|
"driver_media_type", "ink_set", "colour_space", "preset_id",
|
||||||
|
"calibration_url", "apply_calibration", "created", "updated",
|
||||||
|
"id", "name", "notes",
|
||||||
|
] {
|
||||||
|
XCTAssertNotNil(object[key], "missing key \(key)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
let decoded = try decoder.decode(MediaRecipe.self, from: data)
|
||||||
|
XCTAssertEqual(decoded, makeRecipe())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingRequiredKeyThrows() {
|
||||||
|
for key in ["id", "name", "printer_id", "colour_space", "preset_id"] {
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"id": "r1", "name": "n", "printer_id": "q",
|
||||||
|
"colour_space": "rgb", "preset_id": "p",
|
||||||
|
]
|
||||||
|
dict.removeValue(forKey: key)
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try JSONDecoder().decode(MediaRecipe.self, from: data),
|
||||||
|
"expected throw without \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownKeysIgnored() throws {
|
||||||
|
let dict: [String: Any] = [
|
||||||
|
"id": "r1", "name": "n", "printer_id": "q",
|
||||||
|
"colour_space": "rgb", "preset_id": "p",
|
||||||
|
"future_field": "ignored",
|
||||||
|
]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: dict)
|
||||||
|
let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data)
|
||||||
|
XCTAssertEqual(recipe.id, "r1")
|
||||||
|
XCTAssertEqual(recipe.notes, "")
|
||||||
|
XCTAssertFalse(recipe.applyCalibration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidatedGoldens() {
|
||||||
|
var r = makeRecipe()
|
||||||
|
|
||||||
|
r.name = " "
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.printerID = ""
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.presetID = ""
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = makeRecipe()
|
||||||
|
r.colourSpace = "lab"
|
||||||
|
XCTAssertThrowsError(try r.validated()) {
|
||||||
|
XCTAssertEqual(
|
||||||
|
$0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] {
|
||||||
|
r = makeRecipe()
|
||||||
|
r.calibrationURL = bad
|
||||||
|
XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") {
|
||||||
|
XCTAssertEqual(
|
||||||
|
$0 as? MediaRecipe.ValidationError,
|
||||||
|
.invalidCalibrationURL(bad))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testColourSpaceNormalisedToLowercase() throws {
|
||||||
|
var r = makeRecipe()
|
||||||
|
r.colourSpace = "RGB"
|
||||||
|
let validated = try r.validated()
|
||||||
|
XCTAssertEqual(validated.colourSpace, "rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalPrefixedCalNameIsSchemaValid() throws {
|
||||||
|
var r = makeRecipe()
|
||||||
|
// CAL_ refusal is an apply-time policy, not a schema error.
|
||||||
|
r.calibrationURL = "/tmp/CAL_target.cal"
|
||||||
|
XCTAssertNoThrow(try r.validated())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #149 — `ProjectSession` open/save/new/close against an
|
||||||
|
/// isolated `TestAppEnvironment`. Disk artefacts always win over the
|
||||||
|
/// project JSON (R18); a `CAL_` basename is never persisted (R11).
|
||||||
|
@MainActor
|
||||||
|
final class ProjectSessionTests: XCTestCase {
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var project: ProjectSession!
|
||||||
|
private var cwd: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
env = try TestAppEnvironment.make()
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
project = workflow.project
|
||||||
|
cwd = env.root.appendingPathComponent("proj-cwd")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: cwd, withIntermediateDirectories: true)
|
||||||
|
// Recents are pushed asynchronously; drain before asserting.
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
env?.cleanup()
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
project = nil
|
||||||
|
cwd = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets pending `Task`s in the view models run.
|
||||||
|
private func flush() async {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func artefact(_ ext: String, stem: String = "job") throws {
|
||||||
|
try "x".write(
|
||||||
|
to: cwd.appendingPathComponent("\(stem).\(ext)"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeProjectFile(
|
||||||
|
_ name: String = "job.icceryproj",
|
||||||
|
basename: String = "job",
|
||||||
|
cwdOverride: String? = nil,
|
||||||
|
lastVerification: Bool = false,
|
||||||
|
mediaRecipeID: String? = nil,
|
||||||
|
presetID: String? = nil
|
||||||
|
) throws -> URL {
|
||||||
|
let snapshot: VerificationSnapshot? = lastVerification
|
||||||
|
? VerificationSnapshot(
|
||||||
|
date: Date(), avgDE00: 0.7, maxDE00: 1.9,
|
||||||
|
status: "excellent", profileFilename: "\(basename).icc")
|
||||||
|
: nil
|
||||||
|
let p = ICCeryProject(
|
||||||
|
name: "Fixture Project",
|
||||||
|
basename: basename,
|
||||||
|
cwd: cwdOverride ?? cwd.path,
|
||||||
|
mediaRecipeID: mediaRecipeID,
|
||||||
|
presetID: presetID,
|
||||||
|
lastVerification: snapshot)
|
||||||
|
let url = env.root.appendingPathComponent(name)
|
||||||
|
try p.save(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Open
|
||||||
|
|
||||||
|
func testOpenAppliesBasenameCwdAndBinds() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
try artefact("ti2")
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(workflow.targetBasename, "job")
|
||||||
|
XCTAssertEqual(workflow.targetDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(project.projectURL, url)
|
||||||
|
XCTAssertEqual(project.project?.name, "Fixture Project")
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project")
|
||||||
|
XCTAssertFalse(project.isDirty)
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenDiskWinsOverJsonStage() async throws {
|
||||||
|
// JSON claims a finished profile; disk only has .ti2 (R18).
|
||||||
|
try artefact("ti2")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
XCTAssertTrue(project.diskBehindNotes)
|
||||||
|
let notice = workflow.wizard.notice
|
||||||
|
XCTAssertEqual(notice?.kind, .info)
|
||||||
|
XCTAssertTrue(
|
||||||
|
notice?.text.contains("artefacts on disk stop at .ti2") == true,
|
||||||
|
"got: \(notice?.text ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenWithTi3ButNoIccLocksStage5Only() async throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
|
||||||
|
XCTAssertTrue(project.diskBehindNotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenSchema2LeavesLiveStateUntouched() async throws {
|
||||||
|
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||||
|
let url = env.root.appendingPathComponent("v2.icceryproj")
|
||||||
|
try """
|
||||||
|
{"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"}
|
||||||
|
""".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("not schema 1") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenMissingCwdPresentsRelocateSheet() async throws {
|
||||||
|
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||||
|
let url = try writeProjectFile(cwdOverride: gone)
|
||||||
|
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
|
||||||
|
XCTAssertTrue(project.showingRelocateSheet)
|
||||||
|
XCTAssertNotNil(project.pendingRelocate)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "live")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRelocateCancelAbortsOpen() async throws {
|
||||||
|
let gone = env.root.appendingPathComponent("no-such-dir").path
|
||||||
|
let url = try writeProjectFile(cwdOverride: gone)
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
project.cancelRelocate()
|
||||||
|
|
||||||
|
XCTAssertFalse(project.showingRelocateSheet)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenUnknownMediaRecipeStillOpens() async throws {
|
||||||
|
let url = try writeProjectFile(mediaRecipeID: "recipe-absent")
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
XCTAssertEqual(project.projectURL, url)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
// The unknown recipe was ignored, not fatal.
|
||||||
|
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenAppliesPresetWhenNoRecipe() async throws {
|
||||||
|
let url = try writeProjectFile(presetID: "preset-std-rgb")
|
||||||
|
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
func testSaveRefusesEmptyBasename() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = ""
|
||||||
|
|
||||||
|
XCTAssertFalse(project.canSave)
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
// The file keeps its original basename.
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.basename, "job")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveRefusesEmptyCwd() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.workingDirectory = nil
|
||||||
|
|
||||||
|
XCTAssertFalse(project.canSave)
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameRefusedWithoutPersistedOriginal() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
// Bare CAL_ stem — no persisted original to trust (R11).
|
||||||
|
workflow.wizard.basename = "CAL_job"
|
||||||
|
workflow.wizard.calibrationOriginalBasename = ""
|
||||||
|
|
||||||
|
XCTAssertTrue(project.canSave) // enabled so the banner shows
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
|
||||||
|
XCTAssertFalse(saved)
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains(
|
||||||
|
"Finish or exit calibration") == true)
|
||||||
|
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
XCTAssertFalse(raw.contains("CAL_"))
|
||||||
|
XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameSavesPersistedOriginal() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "CAL_job"
|
||||||
|
workflow.wizard.calibrationOriginalBasename = "job"
|
||||||
|
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.basename, "job")
|
||||||
|
XCTAssertFalse(rawContainsCal(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rawContainsCal(_ url: URL) -> Bool {
|
||||||
|
guard let raw = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return raw.contains("CAL_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveCapturesLiveFields() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
|
||||||
|
]
|
||||||
|
workflow.print.selectedPrinter = "Mock_Queue"
|
||||||
|
workflow.selectedPresetID = "preset-std-rgb"
|
||||||
|
workflow.profile.calibrationFile = "/tmp/live.cal"
|
||||||
|
|
||||||
|
XCTAssertTrue(project.isDirty)
|
||||||
|
await flush() // title recompute is deferred one main turn
|
||||||
|
XCTAssertTrue(project.windowTitle.hasSuffix("•"))
|
||||||
|
|
||||||
|
let saved = await project.saveProjectAsync()
|
||||||
|
XCTAssertTrue(saved)
|
||||||
|
let reloaded = try ICCeryProject.load(from: url)
|
||||||
|
XCTAssertEqual(reloaded.printerID, "Mock_Queue")
|
||||||
|
XCTAssertEqual(
|
||||||
|
reloaded.printerDisplayName, "Mock Queue Display")
|
||||||
|
XCTAssertEqual(reloaded.presetID, "preset-std-rgb")
|
||||||
|
XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal")
|
||||||
|
XCTAssertFalse(project.isDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - New / Close
|
||||||
|
|
||||||
|
func testNewClearsBasenameKeepsArtefacts() async throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertTrue(project.showingNewAlert)
|
||||||
|
project.confirmNew()
|
||||||
|
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "")
|
||||||
|
XCTAssertEqual(workflow.targetBasename, "")
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||||
|
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
|
||||||
|
XCTAssertEqual(workflow.wizard.sessionMode, .profile)
|
||||||
|
// The artefact is untouched; the stepper just can't see it.
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: cwd.appendingPathComponent("job.ti3").path))
|
||||||
|
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewWhileChildLiveBannersInstead() async throws {
|
||||||
|
workflow.measurement.isChartreadRunning = true
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertFalse(project.showingNewAlert)
|
||||||
|
XCTAssertNotNil(workflow.wizard.notice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDirtyNewShowsDirtyAlert() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "changed"
|
||||||
|
|
||||||
|
project.requestNew()
|
||||||
|
XCTAssertFalse(project.showingNewAlert)
|
||||||
|
XCTAssertTrue(project.showingDirtyAlert)
|
||||||
|
|
||||||
|
// Don't Save → New proceeds.
|
||||||
|
project.resolveDirty(save: false)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "")
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloseKeepsLiveSession() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
project.requestClose()
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(workflow.wizard.basename, "job")
|
||||||
|
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
|
||||||
|
XCTAssertEqual(project.windowTitle, "ICCery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDirtyCloseSaveThenCloses() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
workflow.wizard.basename = "renamed"
|
||||||
|
|
||||||
|
project.requestClose()
|
||||||
|
XCTAssertTrue(project.showingDirtyAlert)
|
||||||
|
|
||||||
|
project.resolveDirty(save: true)
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
XCTAssertEqual(try ICCeryProject.load(from: url).basename,
|
||||||
|
"renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Report / recents
|
||||||
|
|
||||||
|
func testSaveReportWritesMarkdown() async throws {
|
||||||
|
try artefact("ti1")
|
||||||
|
try artefact("ti3")
|
||||||
|
let url = try writeProjectFile(lastVerification: true)
|
||||||
|
await project.openAsync(url)
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
project.saveReport()
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
let report = cwd.appendingPathComponent("job-report.md")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: report.path))
|
||||||
|
let text = try String(contentsOf: report, encoding: .utf8)
|
||||||
|
XCTAssertTrue(text.contains("job.ti1 | exists"))
|
||||||
|
XCTAssertTrue(text.contains("job.ti2 | missing"))
|
||||||
|
XCTAssertTrue(text.contains("job.ti3 | exists"))
|
||||||
|
XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("job-report.md") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenPushesRecent() async throws {
|
||||||
|
let url = try writeProjectFile()
|
||||||
|
await project.openAsync(url)
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
let recents = project.recents
|
||||||
|
XCTAssertEqual(recents.first?.path, url.path)
|
||||||
|
XCTAssertEqual(recents.first?.name, "Fixture Project")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenRecentMissingFileDropped() async throws {
|
||||||
|
let store = env.environment.recentProjectsStore
|
||||||
|
let ghost = env.root.appendingPathComponent("ghost.icceryproj")
|
||||||
|
try await store.add(url: ghost, name: "Ghost")
|
||||||
|
|
||||||
|
project.openRecent(
|
||||||
|
RecentProjectEntry(name: "Ghost", path: ghost.path))
|
||||||
|
try await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
workflow.wizard.notice?.text.contains("gone") == true)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
XCTAssertNil(project.projectURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue #149 — `recent_projects.json`: cap 20, newest first, dedupe
|
||||||
|
/// by path, missing files pruned on submenu build, Clear Menu wipes the
|
||||||
|
/// recents file only, corrupt file kept (R12).
|
||||||
|
final class RecentProjectsStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private var root: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-recents-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
if let root { try? FileManager.default.removeItem(at: root) }
|
||||||
|
root = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storeURL: URL {
|
||||||
|
root.appendingPathComponent("recent_projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFile(_ name: String) throws -> URL {
|
||||||
|
let url = root.appendingPathComponent("\(name).icceryproj")
|
||||||
|
try "{}".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCapTwentyNewestFirst() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
for i in 0..<25 {
|
||||||
|
let url = try projectFile("p\(i)")
|
||||||
|
try await store.add(url: url, name: "P\(i)")
|
||||||
|
}
|
||||||
|
let all = try await store.load()
|
||||||
|
XCTAssertEqual(all.count, 20)
|
||||||
|
XCTAssertEqual(all.first?.name, "P24")
|
||||||
|
XCTAssertEqual(all.last?.name, "P5")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddDeduplicatesByPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("dup")
|
||||||
|
try await store.add(url: url, name: "First")
|
||||||
|
try await store.add(url: try projectFile("other"), name: "Other")
|
||||||
|
try await store.add(url: url, name: "Second")
|
||||||
|
|
||||||
|
let all = try await store.load()
|
||||||
|
XCTAssertEqual(all.count, 2)
|
||||||
|
XCTAssertEqual(all.first?.name, "Second")
|
||||||
|
XCTAssertEqual(
|
||||||
|
all.filter { $0.path == url.path }.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruneMissingDropsGoneFiles() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let live = try projectFile("live")
|
||||||
|
try await store.add(url: live, name: "Live")
|
||||||
|
try await store.add(
|
||||||
|
url: root.appendingPathComponent("gone.icceryproj"),
|
||||||
|
name: "Gone")
|
||||||
|
|
||||||
|
let pruned = try await store.pruneMissing()
|
||||||
|
XCTAssertEqual(pruned.count, 1)
|
||||||
|
XCTAssertEqual(pruned.first?.name, "Live")
|
||||||
|
|
||||||
|
// The rewrite persisted the drop.
|
||||||
|
let reloaded = try await RecentProjectsStore(url: storeURL).load()
|
||||||
|
XCTAssertEqual(reloaded.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveByPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("a")
|
||||||
|
try await store.add(url: url, name: "A")
|
||||||
|
let removed = try await store.remove(path: url.path)
|
||||||
|
XCTAssertTrue(removed)
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
let second = try await store.remove(path: url.path)
|
||||||
|
XCTAssertFalse(second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearWipesRecentsFileOnly() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let projectURL = try projectFile("keep")
|
||||||
|
try await store.add(url: projectURL, name: "Keep")
|
||||||
|
|
||||||
|
try await store.clear()
|
||||||
|
|
||||||
|
let loaded = try await store.load()
|
||||||
|
XCTAssertTrue(loaded.isEmpty)
|
||||||
|
// The `.icceryproj` itself survives Clear Menu.
|
||||||
|
XCTAssertTrue(
|
||||||
|
FileManager.default.fileExists(atPath: projectURL.path))
|
||||||
|
let raw = try String(contentsOf: storeURL, encoding: .utf8)
|
||||||
|
XCTAssertTrue(raw.contains("["))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCorruptFileThrowsAndKeepsBytes() async throws {
|
||||||
|
try "not json".write(
|
||||||
|
to: storeURL, atomically: true, encoding: .utf8)
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
|
||||||
|
await assertAsyncThrows(expectedType: DecodingError.self) {
|
||||||
|
try await store.load()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(
|
||||||
|
try String(contentsOf: storeURL, encoding: .utf8), "not json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEntryStoresBookmarkAndPath() async throws {
|
||||||
|
let store = RecentProjectsStore(url: storeURL)
|
||||||
|
let url = try projectFile("b")
|
||||||
|
try await store.add(url: url, name: "B")
|
||||||
|
let entry = try await store.load().first
|
||||||
|
XCTAssertEqual(entry?.path, url.path)
|
||||||
|
XCTAssertNotNil(entry?.bookmark)
|
||||||
|
XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBookmarkHashStableAcrossStores() async throws {
|
||||||
|
let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj")
|
||||||
|
let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj")
|
||||||
|
XCTAssertEqual(a.bookmarkHash, b.bookmarkHash)
|
||||||
|
let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj")
|
||||||
|
XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// `SpotReadArgs` goldens (issue #148):
|
||||||
|
/// `spotread -v -e [-c port] [-Y l]` — never `-u`, never a basename,
|
||||||
|
/// `-c` only for ports > 1, `-Y l` only when the LED setting is on.
|
||||||
|
final class SpotReadArgsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testAutoOmitsPort() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig())
|
||||||
|
XCTAssertEqual(args, ["-v", "-e"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPort1OmitsC() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 1))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPort2IncludesC() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(selectedPort: 2))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-c", "2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLedFlag() {
|
||||||
|
let args = SpotReadArgs.build(config: SpotReadConfig(enableLEDs: true))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPortAndLeds() {
|
||||||
|
let args = SpotReadArgs.build(
|
||||||
|
config: SpotReadConfig(selectedPort: 2, enableLEDs: true))
|
||||||
|
XCTAssertEqual(args, ["-v", "-e", "-c", "2", "-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverU() {
|
||||||
|
for config in [
|
||||||
|
SpotReadConfig(),
|
||||||
|
SpotReadConfig(selectedPort: 2),
|
||||||
|
SpotReadConfig(enableLEDs: true),
|
||||||
|
SpotReadConfig(selectedPort: 3, enableLEDs: true),
|
||||||
|
] {
|
||||||
|
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-u"))
|
||||||
|
XCTAssertFalse(SpotReadArgs.build(config: config).contains("-d"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// `SpotReadClassifier` / `SpotReadParser` against real `spotread`
|
||||||
|
/// phrasing (issue #148). The calibration-tile line classifies through
|
||||||
|
/// `ChartreadClassifier`; the spot prompt and its continuation lines
|
||||||
|
/// need the spot-specific matchers.
|
||||||
|
final class SpotReadClassifierTests: XCTestCase {
|
||||||
|
|
||||||
|
// Real `spotread` stdout (calibration then spot prompt).
|
||||||
|
private let calibrateLines = [
|
||||||
|
"Spot read needs a calibration before continuing",
|
||||||
|
"Place instrument on spot reading white calibration tile,",
|
||||||
|
" and then hit any key to continue,",
|
||||||
|
"or hit Esc or Q to abort:",
|
||||||
|
]
|
||||||
|
private let spotPromptLines = [
|
||||||
|
"Place instrument on a spot to be measured,",
|
||||||
|
" and hit a key to take a reading,",
|
||||||
|
"or hit Esc or Q to abort:",
|
||||||
|
]
|
||||||
|
|
||||||
|
func testCalibrationPrompt() {
|
||||||
|
var state = ChartreadState.idle
|
||||||
|
for line in calibrateLines {
|
||||||
|
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state, .calibrating)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpotPromptIsAwaitingTrigger() {
|
||||||
|
var state = ChartreadState.calibrating
|
||||||
|
for line in spotPromptLines {
|
||||||
|
state = SpotReadClassifier.classify(line: line, previousState: state).state
|
||||||
|
}
|
||||||
|
XCTAssertEqual(state, .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAbortLineDoesNotBecomeWarning() {
|
||||||
|
// "or hit Esc or Q to abort:" contains no '?' but does contain
|
||||||
|
// "abort" — it must stay on the current prompt, never flip to
|
||||||
|
// a warning.
|
||||||
|
let r = SpotReadClassifier.classify(
|
||||||
|
line: "or hit Esc or Q to abort:", previousState: .awaitingStrip)
|
||||||
|
XCTAssertEqual(r.state, .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseResultLine() throws {
|
||||||
|
let parsed = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")
|
||||||
|
let lab = try XCTUnwrap(parsed?.lab)
|
||||||
|
XCTAssertEqual(lab.l, 51.9, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(lab.a, -8.3, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(lab.b, 12.2, accuracy: 0.001)
|
||||||
|
let xyz = try XCTUnwrap(parsed?.xyz)
|
||||||
|
XCTAssertEqual(xyz.x, 18.51, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(xyz.y, 20.05, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(xyz.z, 15.71, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseLabOnlyLine() throws {
|
||||||
|
let parsed = SpotReadParser.parse(line: "Result is Lab: 40.0 1.2 -3.4")
|
||||||
|
let lab = try XCTUnwrap(parsed?.lab)
|
||||||
|
XCTAssertEqual(lab.l, 40.0, accuracy: 0.001)
|
||||||
|
XCTAssertNil(parsed?.xyz)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonSampleLineParsesNil() {
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: "Place instrument on a spot to be measured,"))
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: "Calibration successful."))
|
||||||
|
XCTAssertNil(SpotReadParser.parse(line: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeltaEBetweenFixtures() {
|
||||||
|
let a = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 18.51 20.05 15.71, D50 Lab: 51.9 -8.3 12.2")!.lab
|
||||||
|
let b = SpotReadParser.parse(
|
||||||
|
line: "Result is XYZ: 19.00 20.50 16.00, D50 Lab: 52.3 -8.0 12.6")!.lab
|
||||||
|
XCTAssertEqual(ColorDifference.deltaE00(a, a), 0, accuracy: 0.0001)
|
||||||
|
XCTAssertGreaterThan(ColorDifference.deltaE00(a, b), 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 1.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.good)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.warning)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0),
|
||||||
|
.bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
|
||||||
|
/// Issue #148 — `SpotReadViewModel` under an isolated
|
||||||
|
/// `TestAppEnvironment` with per-test mock `spotread`/`instlist`
|
||||||
|
/// sidecars in a temp bin dir.
|
||||||
|
@MainActor
|
||||||
|
final class SpotReadViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
private var env: TestAppEnvironment!
|
||||||
|
private var workflow: TargetWorkflowViewModel!
|
||||||
|
private var spot: SpotReadViewModel!
|
||||||
|
private var binDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
binDir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("spot-bin-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: binDir, withIntermediateDirectories: true)
|
||||||
|
// `bundledArgyllRoot` also points at the temp bin dir so the
|
||||||
|
// real sidecars copied into the host app by the build phase do
|
||||||
|
// not mask a missing `spotread` in the override dir.
|
||||||
|
env = try TestAppEnvironment.make(
|
||||||
|
argyllBinDir: binDir, bundledArgyllRoot: binDir)
|
||||||
|
workflow = TargetWorkflowViewModel(environment: env.environment)
|
||||||
|
spot = workflow.spotRead
|
||||||
|
workflow.wizard.setTarget(basename: "spot", workingDirectory: env.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
spot?.stopIfNeeded()
|
||||||
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||||
|
env?.cleanup()
|
||||||
|
try? FileManager.default.removeItem(at: binDir)
|
||||||
|
env = nil
|
||||||
|
workflow = nil
|
||||||
|
spot = nil
|
||||||
|
binDir = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func writeMock(_ name: String, _ body: String) throws {
|
||||||
|
let url = binDir.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installInstlist(_ devicesJson: String) throws {
|
||||||
|
try writeMock("instlist", """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s' '\(devicesJson)'
|
||||||
|
exit 0
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installSpotread(lab: String = "51.9 -8.3 12.2") throws {
|
||||||
|
try writeMock("spotread", """
|
||||||
|
#!/bin/sh
|
||||||
|
echo "Spot read needs a calibration before continuing"
|
||||||
|
echo "Place instrument on spot reading white calibration tile,"
|
||||||
|
echo " and then hit any key to continue,"
|
||||||
|
echo "or hit Esc or Q to abort:"
|
||||||
|
IFS= read -r line || exit 0
|
||||||
|
echo "Calibration successful."
|
||||||
|
while true; do
|
||||||
|
echo "Place instrument on a spot to be measured,"
|
||||||
|
echo " and hit a key to take a reading,"
|
||||||
|
echo "or hit Esc or Q to abort:"
|
||||||
|
IFS= read -r line || exit 0
|
||||||
|
case "$line" in
|
||||||
|
q*|Q*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
echo "Result is XYZ: 18.51 20.05 15.71, D50 Lab: \(lab)"
|
||||||
|
done
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(
|
||||||
|
_ predicate: @escaping () async -> Bool,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await predicate() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
return await predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSync(
|
||||||
|
_ predicate: @escaping () -> Bool,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if predicate() { return true }
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
return predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Missing sidecar
|
||||||
|
|
||||||
|
func testMissingSidecarNoSpawn() async throws {
|
||||||
|
// bin dir has no spotread → resolver override misses and the
|
||||||
|
// bundled path does not exist either.
|
||||||
|
XCTAssertFalse(spot.sidecarAvailable)
|
||||||
|
spot.sheetOpened()
|
||||||
|
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
|
||||||
|
|
||||||
|
spot.start()
|
||||||
|
XCTAssertEqual(spot.lastError, "spotread sidecar missing — run fetch-argyll")
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
let running = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
XCTAssertFalse(running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - defaultInstrument seeding
|
||||||
|
|
||||||
|
func testDefaultInstrumentSeedsPicker() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":1,"name":"X-Rite i1Pro","type":"i1"},
|
||||||
|
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||||
|
""")
|
||||||
|
var settings = env.environment.settingsStore.load()
|
||||||
|
settings.defaultInstrument = "CM"
|
||||||
|
try env.environment.settingsStore.save(settings)
|
||||||
|
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok1 = await waitFor { !self.spot.isDetecting && !self.spot.instruments.isEmpty }
|
||||||
|
XCTAssertTrue(ok1)
|
||||||
|
guard case .device(let device) = spot.selectedInstrument else {
|
||||||
|
XCTFail("Expected device selection, got .auto")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(device.port, 2)
|
||||||
|
XCTAssertFalse(spot.defaultMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultInstrumentNotPresent() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":1,"name":"X-Rite i1Pro","type":"i1"}]}
|
||||||
|
""")
|
||||||
|
var settings = env.environment.settingsStore.load()
|
||||||
|
settings.defaultInstrument = "51" // Spyder X — absent
|
||||||
|
try env.environment.settingsStore.save(settings)
|
||||||
|
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok2 = await waitFor { !self.spot.isDetecting }
|
||||||
|
XCTAssertTrue(ok2)
|
||||||
|
XCTAssertEqual(spot.selectedInstrument, .auto)
|
||||||
|
XCTAssertTrue(spot.defaultMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetDefaultToggleWritesSettingsOnly() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
try installInstlist("""
|
||||||
|
{"event":"instruments","devices":[
|
||||||
|
{"port":2,"name":"ColorMunki Photo","type":"CM"}]}
|
||||||
|
""")
|
||||||
|
spot.sheetOpened()
|
||||||
|
let ok3 = await waitFor { !self.spot.instruments.isEmpty }
|
||||||
|
XCTAssertTrue(ok3)
|
||||||
|
spot.selectedInstrument = .device(spot.instruments[0])
|
||||||
|
spot.applyDefaultToggle(true)
|
||||||
|
XCTAssertEqual(env.environment.settingsStore.load().defaultInstrument, "CM")
|
||||||
|
// printtarg instrument is untouched (R15).
|
||||||
|
XCTAssertEqual(workflow.instrument, .i1)
|
||||||
|
spot.applyDefaultToggle(false)
|
||||||
|
XCTAssertNil(env.environment.settingsStore.load().defaultInstrument)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exclusive lease
|
||||||
|
|
||||||
|
func testDuplicateSpotreadIdRejected() async throws {
|
||||||
|
try writeMock("spotread", "#!/bin/sh\nsleep 30\n")
|
||||||
|
let pm = env.environment.runner.processManager
|
||||||
|
let bin = env.environment.runner.binaryResolver.resolve("spotread")
|
||||||
|
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||||
|
let ok4 = await pm.isRunning(ProcessID.spotread)
|
||||||
|
XCTAssertTrue(ok4)
|
||||||
|
do {
|
||||||
|
try await pm.runStreaming(id: ProcessID.spotread, binary: bin, arguments: [])
|
||||||
|
XCTFail("Expected duplicateID")
|
||||||
|
} catch let error as ProcessError {
|
||||||
|
guard case .duplicateID(let id) = error else {
|
||||||
|
XCTFail("Expected duplicateID, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
XCTAssertEqual(id, "spotread")
|
||||||
|
}
|
||||||
|
await pm.kill(id: ProcessID.spotread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
func testMockSpotreadProducesSample() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
spot.sheetOpened()
|
||||||
|
spot.start()
|
||||||
|
let ok5 = await waitFor { self.spot.state == .calibrating }
|
||||||
|
XCTAssertTrue(ok5, "expected calibrating prompt")
|
||||||
|
|
||||||
|
spot.calibrate()
|
||||||
|
let ok6 = await waitFor { self.spot.state == .awaitingStrip }
|
||||||
|
XCTAssertTrue(ok6, "expected read prompt")
|
||||||
|
|
||||||
|
spot.trigger()
|
||||||
|
let ok7 = await waitFor { !self.spot.samples.isEmpty }
|
||||||
|
XCTAssertTrue(ok7, "expected a sample")
|
||||||
|
let sample = try XCTUnwrap(spot.samples.first)
|
||||||
|
XCTAssertEqual(sample.lab.l, 51.9, accuracy: 0.001)
|
||||||
|
XCTAssertNotNil(sample.xyz)
|
||||||
|
XCTAssertNil(spot.displayedDeltaE) // first sample hides ΔE
|
||||||
|
|
||||||
|
spot.trigger()
|
||||||
|
let ok8 = await waitFor { self.spot.samples.count >= 2 }
|
||||||
|
XCTAssertTrue(ok8, "expected a second sample")
|
||||||
|
XCTAssertNotNil(spot.displayedDeltaE)
|
||||||
|
XCTAssertEqual(spot.displayedDeltaE ?? -1, 0, accuracy: 0.0001) // identical Lab
|
||||||
|
|
||||||
|
spot.stopIfNeeded()
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
let deadline = Date().addingTimeInterval(5)
|
||||||
|
var alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
while alive && Date() < deadline {
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
alive = await env.environment.runner.processManager.isRunning(ProcessID.spotread)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(alive, "spotread child must not outlive Stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStartBlockedWhileChartreadRunning() async throws {
|
||||||
|
try installSpotread()
|
||||||
|
// Simulate a live Stage 3 chartread child.
|
||||||
|
workflow.measurement.isChartreadRunning = true
|
||||||
|
spot.sheetOpened()
|
||||||
|
spot.start()
|
||||||
|
XCTAssertEqual(spot.lastError, "Stop the Stage 3 chart read first.")
|
||||||
|
XCTAssertFalse(spot.isRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,24 @@ struct TestAppEnvironment {
|
|||||||
var historyURL: URL {
|
var historyURL: URL {
|
||||||
root.appendingPathComponent("verification_history.json")
|
root.appendingPathComponent("verification_history.json")
|
||||||
}
|
}
|
||||||
|
var mediaLibraryURL: URL {
|
||||||
|
root.appendingPathComponent("media_library.json")
|
||||||
|
}
|
||||||
|
var recentProjectsURL: URL {
|
||||||
|
root.appendingPathComponent("recent_projects.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
/// Creates an isolated environment under `NSTemporaryDirectory()`.
|
||||||
/// Call `cleanup()` when finished.
|
/// Call `cleanup()` when finished.
|
||||||
static func make() throws -> TestAppEnvironment {
|
/// `argyllBinDir` overrides the `BinaryResolver` tool directory so
|
||||||
|
/// tests can point at mock sidecar scripts (#148).
|
||||||
|
/// `bundledArgyllRoot` replaces the real app-bundle sidecar root so
|
||||||
|
/// tests can simulate a missing sidecar even when the build phase
|
||||||
|
/// copied real binaries into the host app.
|
||||||
|
static func make(
|
||||||
|
argyllBinDir: URL? = nil,
|
||||||
|
bundledArgyllRoot: URL? = nil
|
||||||
|
) throws -> TestAppEnvironment {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
.appendingPathComponent("iccery-test-env-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
@@ -41,7 +55,9 @@ struct TestAppEnvironment {
|
|||||||
presetStore: PresetStore(settingsStore: settingsStore),
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
runner: ArgyllRunner(
|
runner: ArgyllRunner(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
binaryResolver: BinaryResolver(overrideDir: nil)
|
binaryResolver: BinaryResolver(
|
||||||
|
bundledRoot: bundledArgyllRoot ?? AppPaths.bundledArgyllDir,
|
||||||
|
overrideDir: argyllBinDir)
|
||||||
),
|
),
|
||||||
cupsService: CupsService(
|
cupsService: CupsService(
|
||||||
processManager: processManager,
|
processManager: processManager,
|
||||||
@@ -49,6 +65,12 @@ struct TestAppEnvironment {
|
|||||||
),
|
),
|
||||||
historyStore: VerificationHistoryStore(
|
historyStore: VerificationHistoryStore(
|
||||||
url: root.appendingPathComponent("verification_history.json")
|
url: root.appendingPathComponent("verification_history.json")
|
||||||
|
),
|
||||||
|
mediaStore: MediaLibraryStore(
|
||||||
|
url: root.appendingPathComponent("media_library.json")
|
||||||
|
),
|
||||||
|
recentProjectsStore: RecentProjectsStore(
|
||||||
|
url: root.appendingPathComponent("recent_projects.json")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return TestAppEnvironment(root: root, environment: environment)
|
return TestAppEnvironment(root: root, environment: environment)
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
private func element(_ id: String) -> XCUIElement {
|
private func element(_ id: String) -> XCUIElement {
|
||||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
if inApp.exists { return inApp }
|
if inApp.exists { return inApp }
|
||||||
|
// Search all sheets (including nested sheets) for the element.
|
||||||
|
// The license window is a nested sheet (sheet presented from AboutView).
|
||||||
|
for sheet in app.sheets.allElementsBoundByIndex {
|
||||||
|
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
}
|
||||||
|
// Fallback to original behavior
|
||||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +78,10 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
|
|
||||||
// SDK 13.1 emits no AXGroup for the sidebar root, and an
|
// SDK 13.1 emits no AXGroup for the sidebar root, and an
|
||||||
// identifier on the container clobbers child identifiers
|
// identifier on the container clobbers child identifiers
|
||||||
// (#130) — measure a stable sidebar child instead.
|
// (#130) — measure a stable sidebar child instead. Query the
|
||||||
let sidebarChild = app.descendants(matching: .any)["presetSelect"]
|
// pop-up by type: the Picker's "Preset" label inherits the same
|
||||||
|
// identifier, so an .any query matches twice.
|
||||||
|
let sidebarChild = app.popUpButtons["presetSelect"]
|
||||||
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
|
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
|
||||||
let before = sidebarChild.frame
|
let before = sidebarChild.frame
|
||||||
|
|
||||||
@@ -83,4 +92,139 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
"Toggling global help must not reflow the sidebar.")
|
"Toggling global help must not reflow the sidebar.")
|
||||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAboutDialogShowsViewLicensesButton() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
_ = waitFor("aboutVersion", timeout: 10)
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
XCTAssertTrue(viewLicensesBtn.exists)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
// License window should open as a sheet
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
XCTAssertTrue(element("licenseWindow").exists)
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
// License window should be dismissed
|
||||||
|
XCTAssertFalse(element("licenseWindow").exists)
|
||||||
|
|
||||||
|
// Close about dialog
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
XCTAssertFalse(element("aboutDialog").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsICCeryLicense() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify ICCery license section exists
|
||||||
|
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(icceryLicenseSection.exists)
|
||||||
|
|
||||||
|
// Verify ICCery license content contains key phrases
|
||||||
|
let icceryLicenseContent = element("icceryLicenseSectionContent")
|
||||||
|
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
|
||||||
|
let licenseText = icceryLicenseContent.value as? String ?? ""
|
||||||
|
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
|
||||||
|
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
|
||||||
|
XCTAssertTrue(licenseText.contains("AGPLv3"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsArgyllLicense() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify ArgyllCMS license section exists
|
||||||
|
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(argyllLicenseSection.exists)
|
||||||
|
|
||||||
|
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
|
||||||
|
let argyllLicenseContent = element("argyllLicenseSectionContent")
|
||||||
|
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
|
||||||
|
let licenseText = argyllLicenseContent.value as? String ?? ""
|
||||||
|
// Should contain either the actual AGPLv3 license or the fallback notice
|
||||||
|
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLicenseWindowShowsAttributionLinks() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
|
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||||
|
openAbout.click()
|
||||||
|
|
||||||
|
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||||
|
viewLicensesBtn.click()
|
||||||
|
|
||||||
|
_ = waitFor("licenseWindow", timeout: 10)
|
||||||
|
|
||||||
|
// Verify attribution section exists
|
||||||
|
let attributionHeader = waitFor("attributionHeader", timeout: 5)
|
||||||
|
XCTAssertTrue(attributionHeader.exists)
|
||||||
|
|
||||||
|
// Verify upstream link
|
||||||
|
let upstreamLink = element("argyllUpstreamLink")
|
||||||
|
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
|
||||||
|
let upstreamLabel = upstreamLink.label
|
||||||
|
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
|
||||||
|
|
||||||
|
// Verify fork link
|
||||||
|
let forkLink = element("argyllForkLink")
|
||||||
|
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
|
||||||
|
let forkLabel = forkLink.label
|
||||||
|
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
|
||||||
|
|
||||||
|
// Verify AGPL isolation note
|
||||||
|
let isolationNote = element("agplIsolationNote")
|
||||||
|
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
|
||||||
|
let noteText = isolationNote.value as? String ?? ""
|
||||||
|
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
|
||||||
|
|
||||||
|
// Close license window
|
||||||
|
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||||
|
closeLicenseBtn.click()
|
||||||
|
|
||||||
|
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||||
|
closeAboutBtn.click()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mock spotread for Milestone10SpotReadUITests.
|
||||||
|
|
||||||
|
Emits the real spotread prompt phrasing; each trigger line produces one
|
||||||
|
"Result is XYZ: …, D50 Lab: …" sample. Override the emitted colour with
|
||||||
|
MOCK_SPOTREAD_LAB / MOCK_SPOTREAD_XYZ. 'q' quits with exit 0.
|
||||||
|
Usage: spotread -v -e [-c port] [-Y l]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LAB = os.environ.get("MOCK_SPOTREAD_LAB", "51.9 -8.3 12.2")
|
||||||
|
XYZ = os.environ.get("MOCK_SPOTREAD_XYZ", "18.51 20.05 15.71")
|
||||||
|
|
||||||
|
|
||||||
|
def read_line():
|
||||||
|
try:
|
||||||
|
return sys.stdin.readline()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Spot read needs a calibration before continuing")
|
||||||
|
print("Place instrument on spot reading white calibration tile,")
|
||||||
|
print(" and then hit any key to continue,")
|
||||||
|
print("or hit Esc or Q to abort:")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if not read_line():
|
||||||
|
return 0
|
||||||
|
print("Calibration successful.")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print("Place instrument on a spot to be measured,")
|
||||||
|
print(" and hit a key to take a reading,")
|
||||||
|
print("or hit Esc or Q to abort:")
|
||||||
|
sys.stdout.flush()
|
||||||
|
line = read_line()
|
||||||
|
if not line:
|
||||||
|
return 0
|
||||||
|
if line.strip().lower().startswith("q"):
|
||||||
|
return 0
|
||||||
|
print("Result is XYZ: %s, D50 Lab: %s" % (XYZ, LAB))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import Foundation
|
||||||
|
import Metal
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
||||||
|
///
|
||||||
|
/// Sheet-opened only: no GPU hit-test assertions (no reliable SceneKit
|
||||||
|
/// click on the runner). Containment itself is covered by
|
||||||
|
/// `GamutContainmentTests`.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10GamutCompareUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
/// GPU-less runners still get the banner/Close assertions (#147).
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
private var appDataDir: URL!
|
||||||
|
private var referenceGamutURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui-m10-gamut-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
appDataDir = testRoot.appendingPathComponent("AppData")
|
||||||
|
referenceGamutURL = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam")
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appDataDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
let inSheet = app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
// Menu popup items live outside the window hierarchy.
|
||||||
|
return app.menuItems[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled`. Layer toggles render as disabled
|
||||||
|
/// placeholders until the async layer load lands — on the macOS 12
|
||||||
|
/// runner `waitFor` alone wins the race against `parse`.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openGamutSheet() {
|
||||||
|
launchApp()
|
||||||
|
waitFor("btnViewGamut").click()
|
||||||
|
_ = waitFor("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLayerTogglesExistWithSRGB() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let srgb = waitUntilEnabled("gamutLayer-sRGB")
|
||||||
|
XCTAssertTrue(srgb.exists)
|
||||||
|
// NSButton checkbox value is 1 when checked.
|
||||||
|
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
||||||
|
|
||||||
|
let compare = waitFor("gamutLayer-compare")
|
||||||
|
XCTAssertTrue(compare.exists)
|
||||||
|
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
||||||
|
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddCompareButtonExists() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
waitFor("btnGamutAddCompare").click()
|
||||||
|
// No ICCERY_TEST_GAMUT_FILE set → the stubbed picker cancels;
|
||||||
|
// the sRGB status must stay non-empty.
|
||||||
|
waitFor("btnGamutOpenGam").click()
|
||||||
|
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let value = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompareGamLoadEnablesToggle() throws {
|
||||||
|
let compareURL = workDir.appendingPathComponent("compare.gam")
|
||||||
|
try FileManager.default.copyItem(at: referenceGamutURL, to: compareURL)
|
||||||
|
app.launchEnvironment["ICCERY_TEST_GAMUT_FILE"] = compareURL.path
|
||||||
|
|
||||||
|
openGamutSheet()
|
||||||
|
waitFor("btnGamutAddCompare").click()
|
||||||
|
waitFor("btnGamutOpenGam").click()
|
||||||
|
|
||||||
|
// The pre-load placeholder also exists — wait for enabled.
|
||||||
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
|
||||||
|
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let value = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(value.contains("compare"), "Status should list the compare layer, got: \(value)")
|
||||||
|
|
||||||
|
let remove = waitFor("btnGamutRemoveCompare")
|
||||||
|
XCTAssertTrue(remove.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||||
|
// A profile with no sibling .gam → the mock iccgamut writes one.
|
||||||
|
let profileURL = workDir.appendingPathComponent("myprinter.icc")
|
||||||
|
try Data("MOCK_ICC".utf8).write(to: profileURL)
|
||||||
|
app.launchEnvironment["ICCERY_TEST_GAMUT_PROFILE"] = profileURL.path
|
||||||
|
app.launchEnvironment["ICCERY_MOCK_GAMUT_SOURCE"] = referenceGamutURL.path
|
||||||
|
|
||||||
|
openGamutSheet()
|
||||||
|
waitFor("btnGamutAddCompare").click()
|
||||||
|
waitFor("btnGamutOpenProfile").click()
|
||||||
|
|
||||||
|
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||||
|
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
||||||
|
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
|
||||||
|
|
||||||
|
// The compare slot's display name is the .gam stem ("myprinter").
|
||||||
|
let status = waitFor("gamutStatusText")
|
||||||
|
let statusValue = status.value as? String ?? ""
|
||||||
|
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInspectPanelIdleStableHeight() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let panel = waitFor("gamutInspectPanel")
|
||||||
|
XCTAssertTrue(panel.exists)
|
||||||
|
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||||
|
XCTAssertTrue(element("gamutStatusText").exists)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManualLabInspectShowsContainment() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
waitFor("gamutLabEntryL").click()
|
||||||
|
element("gamutLabEntryL").typeText("50")
|
||||||
|
element("gamutLabEntryA").click()
|
||||||
|
element("gamutLabEntryA").typeText("0")
|
||||||
|
element("gamutLabEntryB").click()
|
||||||
|
element("gamutLabEntryB").typeText("0")
|
||||||
|
|
||||||
|
waitFor("btnGamutInspectLab").click()
|
||||||
|
|
||||||
|
let inside = waitFor("gamutInspect-sRGB")
|
||||||
|
let value = inside.value as? String ?? inside.label
|
||||||
|
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
||||||
|
XCTAssertTrue(element("gamutInspectL").exists)
|
||||||
|
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResetIdentifierUnchanged() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` is always enabled — including on the fallback
|
||||||
|
/// banner — and dismisses the sheet (#147).
|
||||||
|
func testCloseButtonDismissesSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback banner exists exactly when the host lacks Metal —
|
||||||
|
/// no `SCNView` is mounted on a GPU-less runner, and none may be
|
||||||
|
/// reported unavailable on a GPU host.
|
||||||
|
func testFallbackBannerMatchesGPUAvailability() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
|
||||||
|
/// host — banner plus a working Close, no `SCNView` mounted (#147).
|
||||||
|
/// The env is set for this test only; the default launch env must
|
||||||
|
/// not carry it, or CI's future GPU run would skip SceneKit too.
|
||||||
|
func testForcedSceneKitSkipShowsBannerAndClose() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
let close = waitFor("btnCloseGamut")
|
||||||
|
XCTAssertTrue(close.isEnabled)
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS
|
||||||
|
/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit
|
||||||
|
/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing
|
||||||
|
/// `<ICCERY_TEST_ROOT>/AppData/media_library.json` before launch —
|
||||||
|
/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries
|
||||||
|
/// are by identifier only ("Media" also appears in help overlays).
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
|
||||||
|
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appData, withIntermediateDirectories: true)
|
||||||
|
// A recipe bound to a queue that is never enumerated.
|
||||||
|
let fixture = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "fixture-missing-queue",
|
||||||
|
"name": "Missing Queue Recipe",
|
||||||
|
"notes": "",
|
||||||
|
"printer_id": "No_Such_Queue",
|
||||||
|
"printer_display_name": "Missing Queue",
|
||||||
|
"paper_name": "Rag",
|
||||||
|
"ink_set": "PK",
|
||||||
|
"colour_space": "rgb",
|
||||||
|
"preset_id": "preset-std-rgb",
|
||||||
|
"calibration_url": null,
|
||||||
|
"apply_calibration": false,
|
||||||
|
"created": "2026-09-12T00:00:00Z",
|
||||||
|
"updated": "2026-09-12T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try fixture.write(
|
||||||
|
to: appData.appendingPathComponent("media_library.json"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let el = waitFor(id, timeout: timeout)
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMediaPickerDoesNotReusePresetSelect() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let preset = app.popUpButtons["presetSelect"]
|
||||||
|
XCTAssertTrue(preset.waitForExistence(timeout: 10))
|
||||||
|
let media = app.popUpButtons["mediaSelect"]
|
||||||
|
XCTAssertTrue(media.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureRequiresNamePaperInk() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
// Capture enables once the mock CUPS enumeration selects a queue.
|
||||||
|
let capture = waitForEnabled("btnMediaLibraryCapture")
|
||||||
|
XCTAssertTrue(capture.isEnabled)
|
||||||
|
capture.click()
|
||||||
|
|
||||||
|
_ = waitFor("saveMediaRecipeDialog")
|
||||||
|
let save = element("btnConfirmSaveMedia")
|
||||||
|
XCTAssertTrue(save.exists)
|
||||||
|
XCTAssertFalse(save.isEnabled)
|
||||||
|
|
||||||
|
for (id, text) in [
|
||||||
|
("saveMediaName", "UI Recipe"),
|
||||||
|
("saveMediaPaper", "Rag"),
|
||||||
|
("saveMediaInk", "PK"),
|
||||||
|
] {
|
||||||
|
let field = element(id)
|
||||||
|
field.click()
|
||||||
|
field.typeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(save.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManageApplyMissingPrinterShowsBanner() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let manage = app.buttons["btnMediaLibraryManage"]
|
||||||
|
XCTAssertTrue(manage.waitForExistence(timeout: 10))
|
||||||
|
manage.click()
|
||||||
|
_ = waitFor("manageMediaDialog")
|
||||||
|
|
||||||
|
let apply = element("btnMediaLibraryApply-fixture-missing-queue")
|
||||||
|
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||||
|
apply.click()
|
||||||
|
|
||||||
|
// The window banner (`noticeText`) sits behind this sheet on
|
||||||
|
// Monterey (#170). Assert the in-sheet copy instead.
|
||||||
|
let notice = waitFor("manageMediaNotice", timeout: 15)
|
||||||
|
let text = (notice.value as? String) ?? notice.label
|
||||||
|
XCTAssertTrue(
|
||||||
|
text.contains("is not installed"),
|
||||||
|
"expected not-installed notice, got: \(text)")
|
||||||
|
|
||||||
|
// A failed apply keeps the manage sheet open and the sidebar
|
||||||
|
// picker reverts.
|
||||||
|
XCTAssertTrue(element("manageMediaDialog").exists)
|
||||||
|
XCTAssertTrue(element("mediaSelect").exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
||||||
|
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
||||||
|
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
||||||
|
/// through the File menu when it is in the AX tree, else by their
|
||||||
|
/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX
|
||||||
|
/// exposure (R19). All queries by identifier.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10ProjectUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10p-\(UUID().uuidString)")
|
||||||
|
workDir = testRoot.appendingPathComponent("WorkDir")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: testRoot.appendingPathComponent("AppData"),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
workDir = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noticeText(timeout: TimeInterval = 10) -> String {
|
||||||
|
let el = waitFor("noticeText", timeout: timeout)
|
||||||
|
return (el.value as? String) ?? el.label
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alert/sheet button by visible title, falling back to the a11y
|
||||||
|
/// id; nil when neither matches. macOS 12 SwiftUI alerts often
|
||||||
|
/// drop `accessibilityIdentifier` on their buttons, so the title
|
||||||
|
/// is the reliable handle there.
|
||||||
|
private func alertButton(title: String, id: String) -> XCUIElement? {
|
||||||
|
let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch
|
||||||
|
if inDialog.exists { return inDialog }
|
||||||
|
let inSheet = app.sheets.firstMatch.buttons[title].firstMatch
|
||||||
|
if inSheet.exists { return inSheet }
|
||||||
|
let byId = element(id)
|
||||||
|
return byId.exists ? byId : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires File ▸ New Project via the menu when it is in the AX
|
||||||
|
/// tree, else ⌘N. On macOS 12 `typeKey` may not reach the
|
||||||
|
/// `CommandGroup`, and menu item ids are unreliable — the menu
|
||||||
|
/// item is matched by its "New Project" label first.
|
||||||
|
private func triggerNewProject() {
|
||||||
|
let fileMenu = app.menuBarItems["File"]
|
||||||
|
if fileMenu.waitForExistence(timeout: 5) {
|
||||||
|
fileMenu.click()
|
||||||
|
let byTitle = app.menuItems["New Project"].firstMatch
|
||||||
|
let byId = app.menuItems["menuProjectNew"].firstMatch
|
||||||
|
let item = byTitle.exists ? byTitle : byId
|
||||||
|
if item.waitForExistence(timeout: 5) {
|
||||||
|
item.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
}
|
||||||
|
app.typeKey("n", modifierFlags: .command)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
||||||
|
/// open-picker hook at it.
|
||||||
|
private func stageProjectFixture(
|
||||||
|
basename: String = "ui149job",
|
||||||
|
lastVerification: Bool = false
|
||||||
|
) throws -> URL {
|
||||||
|
let verification = lastVerification ? """
|
||||||
|
"last_verification": {
|
||||||
|
"date": "2026-09-12T00:00:00Z",
|
||||||
|
"avg_de00": 0.7,
|
||||||
|
"max_de00": 1.9,
|
||||||
|
"status": "excellent",
|
||||||
|
"profile_filename": "\(basename).icc"
|
||||||
|
},
|
||||||
|
""" : ""
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "UI Fixture Project",
|
||||||
|
"notes": "",
|
||||||
|
"basename": "\(basename)",
|
||||||
|
"cwd": "\(workDir.path)",
|
||||||
|
"printer_id": null,
|
||||||
|
"media_recipe_id": null,
|
||||||
|
"preset_id": null,
|
||||||
|
"calibration_url": null,
|
||||||
|
\(verification)
|
||||||
|
"updated": "2026-09-13T00:00:00Z"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let url = testRoot.appendingPathComponent("fixture.icceryproj")
|
||||||
|
try json.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func artefact(_ ext: String, stem: String = "ui149job") throws {
|
||||||
|
try "x".write(
|
||||||
|
to: workDir.appendingPathComponent("\(stem).\(ext)"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws {
|
||||||
|
try artefact("ti3")
|
||||||
|
_ = try stageProjectFixture()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
// Open the fixture via the chip — never a real panel.
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
_ = waitFor("projectChipPath")
|
||||||
|
let basenameField = app.textFields["targetBasename"]
|
||||||
|
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
||||||
|
|
||||||
|
// File ▸ New Project when the menu is in the AX tree, else
|
||||||
|
// ⌘N. Mock CUPS may have enumerated a queue that the fixture
|
||||||
|
// does not record, making the session dirty — in that case
|
||||||
|
// the dirty alert gates New first. macOS 12 alerts often lack
|
||||||
|
// button identifiers, so confirm by title with id fallback.
|
||||||
|
triggerNewProject()
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
var confirmed = false
|
||||||
|
while Date() < deadline {
|
||||||
|
// Dirty sessions show the dirty alert first; discarding it
|
||||||
|
// runs the New reset directly (no second confirm).
|
||||||
|
if let discard = alertButton(
|
||||||
|
title: "Don't Save", id: "btnProjectDirtyDiscard") {
|
||||||
|
discard.click()
|
||||||
|
confirmed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if let start = alertButton(
|
||||||
|
title: "Start", id: "btnProjectNewConfirm") {
|
||||||
|
start.click()
|
||||||
|
confirmed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(confirmed, "expected the New or dirty alert")
|
||||||
|
|
||||||
|
// Basename cleared (legal "no target" state — not a
|
||||||
|
// placeholder), fixture `.ti3` untouched on disk.
|
||||||
|
let cleared = basenameField.value as? String ?? ""
|
||||||
|
XCTAssertEqual(cleared, "")
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
|
atPath: workDir.appendingPathComponent("ui149job.ti3").path))
|
||||||
|
XCTAssertTrue(element("projectChip").exists)
|
||||||
|
XCTAssertEqual(
|
||||||
|
element("projectChipName").value as? String, "No project")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenProjectDiskWinsOverJsonStage() throws {
|
||||||
|
// JSON claims a finished profile; disk stops at .ti2 (R18).
|
||||||
|
try artefact("ti2")
|
||||||
|
_ = try stageProjectFixture(lastVerification: true)
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
|
||||||
|
let notice = noticeText()
|
||||||
|
XCTAssertTrue(
|
||||||
|
notice.contains("artefacts on disk stop at .ti2"),
|
||||||
|
"got: \(notice)")
|
||||||
|
XCTAssertTrue(element("projectChipStale").exists)
|
||||||
|
XCTAssertEqual(
|
||||||
|
element("projectChipPath").value as? String, workDir.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveDisabledWithoutBasename() throws {
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
_ = waitFor("projectChip")
|
||||||
|
XCTAssertEqual(element("projectChipName").value as? String, "No project")
|
||||||
|
// Chip Save is hidden while unbound; Save As lives in the menu.
|
||||||
|
XCTAssertFalse(element("btnProjectSave").exists)
|
||||||
|
|
||||||
|
// When the File menu is in the AX tree, Save must be disabled.
|
||||||
|
let fileMenu = app.menuBarItems["File"]
|
||||||
|
if fileMenu.waitForExistence(timeout: 3) {
|
||||||
|
fileMenu.click()
|
||||||
|
let save = app.menuItems["menuProjectSave"].firstMatch
|
||||||
|
if save.waitForExistence(timeout: 3) {
|
||||||
|
XCTAssertFalse(save.isEnabled)
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalBasenameRefused() throws {
|
||||||
|
// A fixture whose stem is CAL_-prefixed binds a live CAL_
|
||||||
|
// basename; the persisted original is empty, so Save must
|
||||||
|
// refuse and never write CAL_ back (R11).
|
||||||
|
let url = try stageProjectFixture(basename: "CAL_ui149")
|
||||||
|
let before = try Data(contentsOf: url)
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
waitFor("btnProjectOpen").click()
|
||||||
|
let save = waitFor("btnProjectSave")
|
||||||
|
XCTAssertTrue(save.isEnabled)
|
||||||
|
save.click()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
noticeText().contains("Finish or exit calibration"),
|
||||||
|
"got: \(noticeText())")
|
||||||
|
XCTAssertEqual(try Data(contentsOf: url), before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 10 UI tests — issue #148 spot-read console. Mock Argyll
|
||||||
|
/// sidecars (`ICCERY_ARGYLL_BINARY_DIR` → `Fixtures/bin`) provide
|
||||||
|
/// `instlist`, `chartread`, and `spotread`; no real USB Detect is ever
|
||||||
|
/// clicked. All queries are by identifier only.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone10SpotReadUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui10spot-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
// Redirect the bundled root too so the real sidecars copied
|
||||||
|
// into the product by the build phase cannot mask a missing
|
||||||
|
// override binary (`testMissingSidecarShowsMessage`).
|
||||||
|
"ICCERY_ARGYLL_BUNDLED_ROOT": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
"ICCERY_TEST_CSV_EXPORT":
|
||||||
|
workDir.appendingPathComponent("spot-history.csv").path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed `wizard_state.json` with a working directory so `btnSpotRead`
|
||||||
|
/// is enabled without driving the whole Stage 1/2 flow.
|
||||||
|
private func seedWorkingDirectory() throws {
|
||||||
|
let appData = testRoot.appendingPathComponent("AppData", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: appData, withIntermediateDirectories: true)
|
||||||
|
let state = """
|
||||||
|
{
|
||||||
|
"currentStage": 0,
|
||||||
|
"basename": "spotui",
|
||||||
|
"cwd": "\(workDir.path)",
|
||||||
|
"sessionMode": "profile"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try state.write(
|
||||||
|
to: appData.appendingPathComponent("wizard_state.json"),
|
||||||
|
atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sidebar gating
|
||||||
|
|
||||||
|
func testSpotReadButtonDisabledWithoutCwd() throws {
|
||||||
|
launchApp()
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertFalse(button.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpotReadButtonDisabledDuringChartread() throws {
|
||||||
|
launchApp()
|
||||||
|
// Drive to Stage 3 with the mock targen/printtarg fixtures.
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
_ = waitFor("btnCreateLayout", timeout: 20)
|
||||||
|
app.buttons["btnCreateLayout"].click()
|
||||||
|
_ = waitFor("galleryPage-0", timeout: 20)
|
||||||
|
_ = waitFor("btnAdvanceToStage3", timeout: 10)
|
||||||
|
app.buttons["btnAdvanceToStage3"].click()
|
||||||
|
_ = waitFor("stage3TargetBasename", timeout: 10)
|
||||||
|
|
||||||
|
// Start the mock chartread — it blocks on the calibrate prompt.
|
||||||
|
app.buttons["btnStartRead"].click()
|
||||||
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.exists)
|
||||||
|
XCTAssertFalse(button.isEnabled)
|
||||||
|
|
||||||
|
// Clean up the live chartread child before teardown.
|
||||||
|
if app.buttons["btnCancel"].exists {
|
||||||
|
app.buttons["btnCancel"].click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet contract
|
||||||
|
|
||||||
|
func testSheetHasOwnInstrumentIds() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(button.isEnabled)
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotInstrumentSelect").waitForExistence(timeout: 10))
|
||||||
|
// Stage 3 ids must not appear inside the sheet.
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.sheets.firstMatch.descendants(matching: .any)["chartreadInstrumentSelect"].exists)
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.sheets.firstMatch.descendants(matching: .any)["btnDetectInstruments"].exists)
|
||||||
|
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingSidecarShowsMessage() throws {
|
||||||
|
// Point the override at an empty dir; the bundled root has no
|
||||||
|
// real sidecars in this checkout, so resolve() misses both.
|
||||||
|
let emptyBin = testRoot.appendingPathComponent("empty-bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: emptyBin, withIntermediateDirectories: true)
|
||||||
|
app.launchEnvironment["ICCERY_ARGYLL_BINARY_DIR"] = emptyBin.path
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotSidecarMissing").waitForExistence(timeout: 10))
|
||||||
|
XCTAssertFalse(element("btnSpotStart").exists)
|
||||||
|
XCTAssertFalse(element("btnSpotDetectInstruments").exists)
|
||||||
|
XCTAssertTrue(element("btnCloseSpotRead").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHistoryCopyDisabledWhenEmpty() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
XCTAssertTrue(element("spotHistoryEmpty").waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(element("spotLastEmpty").exists)
|
||||||
|
XCTAssertFalse(element("btnSpotCopyLab").isEnabled)
|
||||||
|
XCTAssertFalse(element("btnSpotExportCsv").isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full mock session: Start → Calibrate → Read produces one Lab
|
||||||
|
/// sample and enables Copy/Export.
|
||||||
|
func testMockSessionProducesSample() throws {
|
||||||
|
try seedWorkingDirectory()
|
||||||
|
launchApp()
|
||||||
|
|
||||||
|
let button = app.buttons["btnSpotRead"]
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 10))
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while !button.isEnabled, Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
_ = waitFor("spotReadView", timeout: 10)
|
||||||
|
let start = element("btnSpotStart")
|
||||||
|
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||||
|
start.click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("btnSpotCalibrate").waitForExistence(timeout: 15))
|
||||||
|
element("btnSpotCalibrate").click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("btnSpotTrigger").waitForExistence(timeout: 15))
|
||||||
|
element("btnSpotTrigger").click()
|
||||||
|
|
||||||
|
XCTAssertTrue(element("spotLastSample").waitForExistence(timeout: 15))
|
||||||
|
XCTAssertTrue(element("spotLabL").exists)
|
||||||
|
XCTAssertTrue(element("spotSwatch").exists)
|
||||||
|
XCTAssertTrue(element("btnSpotCopyLab").isEnabled)
|
||||||
|
XCTAssertTrue(element("btnSpotExportCsv").isEnabled)
|
||||||
|
|
||||||
|
element("btnSpotStop").click()
|
||||||
|
element("btnCloseSpotRead").click()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
"ICCERY_TEST_ROOT": testRoot.path,
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
"ICCERY_TEST_SAVE_TARGET":
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
@@ -85,6 +86,41 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exists **and** `isEnabled` — guards clicks against buttons that
|
||||||
|
/// appear a beat before their `.disabled` condition clears.
|
||||||
|
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists && el.isEnabled { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(
|
||||||
|
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-asserting existence poll for the retry-or-fail pattern.
|
||||||
|
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if element(id).exists { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return element(id).exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert an element stays absent after a short dwell — unlike
|
||||||
|
/// `waitForExistence`, which always burns its full timeout on the
|
||||||
|
/// negative path.
|
||||||
|
private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5,
|
||||||
|
_ message: String = "expected element to stay absent",
|
||||||
|
file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(dwell))
|
||||||
|
XCTAssertFalse(el.exists, message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
private func staticText(_ exact: String) -> XCUIElement {
|
private func staticText(_ exact: String) -> XCUIElement {
|
||||||
let inApp = app.staticTexts[exact]
|
let inApp = app.staticTexts[exact]
|
||||||
if inApp.exists { return inApp }
|
if inApp.exists { return inApp }
|
||||||
@@ -171,16 +207,24 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
XCTAssertTrue(element("tiffDpi").exists)
|
XCTAssertTrue(element("tiffDpi").exists)
|
||||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||||
|
|
||||||
app.buttons["btnCreateLayout"].click()
|
// On the slow macOS 12 runner a synthesized click can land
|
||||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
// while the button is still rebuilding — retry once if the
|
||||||
|
// gallery never materialises, then allow a generous window
|
||||||
|
// for the fixture printtarg + PNG render.
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
if !existsAfter("galleryPage-0", timeout: 15) {
|
||||||
|
waitUntilEnabled("btnCreateLayout").click()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
|
||||||
XCTAssertTrue(FileManager.default.fileExists(
|
XCTAssertTrue(FileManager.default.fileExists(
|
||||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||||
|
|
||||||
// Print panel is live from M3; a default printer is selected
|
// Print panel is live from M3. GitHub macos-14 has no system
|
||||||
// so both the all-pages and per-page print buttons are enabled.
|
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||||
|
// before Print All / per-page enable (run 34867767434).
|
||||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +360,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
|
assertAbsent(staticText("UI Test Preset"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export a preset to JSON and re-import it (issue #11).
|
/// Export a preset to JSON and re-import it (issue #11).
|
||||||
@@ -348,7 +392,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
let deleteButtons = buttonsMatching(
|
let deleteButtons = buttonsMatching(
|
||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
|
assertAbsent(staticText("RoundTrip"))
|
||||||
|
|
||||||
// Copy the export to the import path so the hook picks it up.
|
// Copy the export to the import path so the hook picks it up.
|
||||||
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
||||||
|
|||||||
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
return recordedLpArgv()
|
return recordedLpArgv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||||
|
/// moves up, clear of the Dock collision zone at the window's
|
||||||
|
/// bottom edge (#132).
|
||||||
|
///
|
||||||
|
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||||
|
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||||
|
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||||
|
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||||
|
/// point that can be a Dock icon — a coordinate click there once
|
||||||
|
/// opened Calendar instead of Print. Callers must click only when
|
||||||
|
/// the returned element `isHittable`; never coordinate-click a
|
||||||
|
/// stale frame.
|
||||||
|
@discardableResult
|
||||||
|
private func scrollStage2UntilHittable(
|
||||||
|
_ identifier: String,
|
||||||
|
timeout: TimeInterval = 20
|
||||||
|
) -> XCUIElement {
|
||||||
|
var button = app.buttons[identifier]
|
||||||
|
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||||
|
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let windowBottom = app.windows.firstMatch.frame.maxY
|
||||||
|
if button.exists, button.isHittable,
|
||||||
|
button.frame.maxY < windowBottom - 80 {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
// Grab the upper half of the cell (the TIFF, not the Print
|
||||||
|
// button / Dock) and drag toward the top of the window.
|
||||||
|
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||||
|
// the Dock zone.
|
||||||
|
if cell.isHittable {
|
||||||
|
let start = cell.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.25))
|
||||||
|
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||||
|
start.press(forDuration: 0.15, thenDragTo: end)
|
||||||
|
} else {
|
||||||
|
// Cell not hit-testable: drag the stage-2 content
|
||||||
|
// directly — still content, still never scrollBars.
|
||||||
|
let scrollView = app.scrollViews["stage-2"]
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.55))
|
||||||
|
.press(forDuration: 0.15, thenDragTo:
|
||||||
|
scrollView.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.15)))
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
button = app.buttons[identifier]
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tests
|
// MARK: - Tests
|
||||||
|
|
||||||
/// Panel appears after the manifest; refresh populates the printer
|
/// Panel appears after the manifest; refresh populates the printer
|
||||||
@@ -198,26 +251,17 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
|
||||||
// The gallery cell's Print button can sit at the window's bottom
|
// The gallery cell's Print button sits at the window's bottom
|
||||||
// edge where XCUI's automatic scroll-to-visible is inert (#132)
|
// edge; scroll until it is genuinely hittable (#132). Never
|
||||||
// — scroll stage-2 explicitly until the hit point is onscreen.
|
// coordinate-click a stale frame — that point can be the Dock.
|
||||||
let printPage = app.buttons["btnPrintPage-0"]
|
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
|
||||||
let stage2 = app.scrollViews["stage-2"]
|
guard printPage.isHittable else {
|
||||||
let scrollDeadline = Date().addingTimeInterval(10)
|
|
||||||
while Date() < scrollDeadline, !printPage.isHittable {
|
|
||||||
stage2.scroll(byDeltaX: 0, deltaY: -1)
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
||||||
}
|
|
||||||
if printPage.isHittable {
|
|
||||||
printPage.click()
|
|
||||||
} else {
|
|
||||||
// LazyVGrid cells can report a stale a11y frame — click the
|
|
||||||
// point directly; the lp argv assert below still verifies.
|
|
||||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||||
"\(app.debugDescription)\nAXTREE-END")
|
"\(app.debugDescription)\nAXTREE-END")
|
||||||
printPage.coordinate(withNormalizedOffset:
|
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
|
||||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
return
|
||||||
}
|
}
|
||||||
|
printPage.click()
|
||||||
let argv = waitForLpLine()
|
let argv = waitForLpLine()
|
||||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||||
|
|||||||
@@ -98,30 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDetectInstruments"].click()
|
app.buttons["btnDetectInstruments"].click()
|
||||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||||
|
|
||||||
// Keep Auto (port 1) and start the session.
|
// Wait until Start is enabled before clicking. Existence-only
|
||||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||||
app.buttons["btnStartRead"].click()
|
driveOnePass(startButton: "btnStartRead")
|
||||||
|
|
||||||
// Calibrate.
|
|
||||||
let calibrate = element("btnCalibrate")
|
|
||||||
if !calibrate.waitForExistence(timeout: 25) {
|
|
||||||
let error = element("chartreadLastError").label
|
|
||||||
let value = element("chartreadLastError").value as? String ?? "<nil>"
|
|
||||||
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
|
|
||||||
}
|
|
||||||
app.buttons["btnCalibrate"].click()
|
|
||||||
|
|
||||||
// Trigger strip A.
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
|
|
||||||
// Trigger strip B.
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
|
|
||||||
// All strips read → Done & Save appears.
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
|
||||||
|
|
||||||
// Averaging panel appears with one pass snapshot.
|
// Averaging panel appears with one pass snapshot.
|
||||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||||
@@ -175,22 +154,59 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
|
|
||||||
/// Runs the mock handheld chartread session to completion
|
/// Runs the mock handheld chartread session to completion
|
||||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||||
|
///
|
||||||
|
/// `isEnabled` can be true in AX while the SwiftUI action is still
|
||||||
|
/// a no-op (runs 35251, 35443). Re-click until the session is
|
||||||
|
/// actually running (`btnCancel` is shown whenever
|
||||||
|
/// `isChartreadRunning`), then wait for Calibrate.
|
||||||
private func driveOnePass(startButton: String) {
|
private func driveOnePass(startButton: String) {
|
||||||
let start = app.buttons[startButton]
|
let start = app.buttons[startButton]
|
||||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||||
let deadline = Date().addingTimeInterval(10)
|
let enabledBy = Date().addingTimeInterval(10)
|
||||||
while Date() < deadline, !start.isEnabled {
|
while Date() < enabledBy, !start.isEnabled {
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
}
|
}
|
||||||
XCTAssertTrue(start.isEnabled)
|
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||||
start.click()
|
|
||||||
|
let sessionBy = Date().addingTimeInterval(25)
|
||||||
|
while Date() < sessionBy, !chartreadSessionRunning {
|
||||||
|
if start.exists, start.isEnabled {
|
||||||
|
start.click()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(
|
||||||
|
chartreadSessionRunning,
|
||||||
|
"\(startButton) click never started chartread; "
|
||||||
|
+ "notice=\(element("noticeText").value as? String ?? "") "
|
||||||
|
+ "start.exists=\(start.exists)"
|
||||||
|
)
|
||||||
|
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
driveStripsUntilDone()
|
||||||
app.buttons["btnTrigger"].click()
|
XCTAssertTrue(element("btnDoneRead").exists)
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `btnCancel` is in the tree for the whole chartread session;
|
||||||
|
/// Calibrate/Trigger only appear after the first classified prompt.
|
||||||
|
private var chartreadSessionRunning: Bool {
|
||||||
|
element("btnCancel").exists
|
||||||
|
|| element("btnCalibrate").exists
|
||||||
|
|| element("btnTrigger").exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||||
|
/// appears — the button is re-polled each pass so a click that
|
||||||
|
/// races a state transition isn't lost.
|
||||||
|
private func driveStripsUntilDone() {
|
||||||
|
let deadline = Date().addingTimeInterval(40)
|
||||||
|
while !element("btnDoneRead").exists, Date() < deadline {
|
||||||
|
if app.buttons["btnTrigger"].waitForExistence(timeout: 10) {
|
||||||
|
app.buttons["btnTrigger"].click()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
|
||||||
|
/// rows with expanding spacers once gave the stage an unbounded ideal
|
||||||
|
/// width, and window centering shifted the 270 pt sidebar into
|
||||||
|
/// negative X (issue #163). AX-tree existence checks cannot see that,
|
||||||
|
/// so assert real frame geometry.
|
||||||
|
func testCalibrationViewDoesNotOverflowWindow() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let window = app.windows.firstMatch
|
||||||
|
XCTAssertTrue(window.exists)
|
||||||
|
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
|
||||||
|
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
|
||||||
|
let ret = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(ret.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(ret.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Return to Profiling" is the single Stage 0 exit and carries the
|
||||||
|
/// cancel-action shortcut, so Escape must dismiss the dashboard too
|
||||||
|
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
|
||||||
|
/// runner (m10 phase-08), so the Escape check falls back to the
|
||||||
|
/// deterministic button tap.
|
||||||
|
func testCalibrationReturnButtonAndEscapeDismiss() throws {
|
||||||
|
let calButton = app.buttons["btnCalibratePrinter"]
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
let returnButton = app.buttons["btnCalReturn"]
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(returnButton.isHittable)
|
||||||
|
returnButton.tap()
|
||||||
|
|
||||||
|
let stage1 = app.descendants(matching: .any)["stage-1"]
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Re-enter and try Escape; fall back to the button where the
|
||||||
|
// runtime does not deliver typeKey.
|
||||||
|
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||||
|
calButton.tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||||
|
if !stage1.waitForExistence(timeout: 4) {
|
||||||
|
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||||
|
returnButton.tap()
|
||||||
|
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A failing calibration targen surfaces the error through the
|
/// A failing calibration targen surfaces the error through the
|
||||||
/// wizard notice and restores the original basename (issue #80).
|
/// wizard notice and restores the original basename (issue #80).
|
||||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Metal
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Milestone6GamutUITests: XCTestCase {
|
final class Milestone6GamutUITests: XCTestCase {
|
||||||
|
|
||||||
|
/// Metal on the test host — the app under test runs on the same
|
||||||
|
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||||
|
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||||
|
|
||||||
private var app: XCUIApplication!
|
private var app: XCUIApplication!
|
||||||
private var testRoot: URL!
|
private var testRoot: URL!
|
||||||
private var binDir: URL!
|
private var binDir: URL!
|
||||||
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
|
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||||
|
if app != nil, element("btnCloseGamut").exists {
|
||||||
|
element("btnCloseGamut").click()
|
||||||
|
}
|
||||||
app?.terminate()
|
app?.terminate()
|
||||||
app = nil
|
app = nil
|
||||||
if let testRoot {
|
if let testRoot {
|
||||||
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||||
|
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||||
|
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||||
|
private func closeGamutSheet() {
|
||||||
|
let close = element("btnCloseGamut")
|
||||||
|
guard close.waitForExistence(timeout: 5) else { return }
|
||||||
|
close.click()
|
||||||
|
waitForGone("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
/// Build and verify the mock profile, then open the native gamut viewer.
|
/// Build and verify the mock profile, then open the native gamut viewer.
|
||||||
/// The viewer should load both the reference sRGB mesh and the profile
|
/// The viewer should load both the reference sRGB mesh and the profile
|
||||||
/// gamut copied from that reference.
|
/// gamut copied from that reference.
|
||||||
func testViewGamutOpensSceneKitSheet() throws {
|
private func openGamutSheet() {
|
||||||
app.launch()
|
app.launch()
|
||||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||||
app.activate()
|
app.activate()
|
||||||
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
|
|
||||||
waitFor("btnViewGamut").click()
|
waitFor("btnViewGamut").click()
|
||||||
|
|
||||||
|
_ = waitFor("gamutView")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewGamutOpensSceneKitSheet() throws {
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
let gamutView = waitFor("gamutView")
|
let gamutView = waitFor("gamutView")
|
||||||
XCTAssertTrue(gamutView.exists)
|
XCTAssertTrue(gamutView.exists)
|
||||||
|
|
||||||
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
|
|||||||
let value = status.value as? String ?? ""
|
let value = status.value as? String ?? ""
|
||||||
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
||||||
|
|
||||||
|
// The fallback banner appears exactly when the host lacks Metal
|
||||||
|
// — no SCNView is constructed without a GPU (#147).
|
||||||
|
if hasGPU {
|
||||||
|
XCTAssertFalse(
|
||||||
|
element("gamutViewerUnavailable").exists,
|
||||||
|
"GPU host must mount the SceneKit view, not the fallback")
|
||||||
|
} else {
|
||||||
|
_ = waitFor("gamutViewerUnavailable")
|
||||||
|
}
|
||||||
|
|
||||||
// The reset button demonstrates that the viewer is interactive.
|
// The reset button demonstrates that the viewer is interactive.
|
||||||
let reset = waitFor("btnResetGamutCamera")
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
XCTAssertTrue(reset.isEnabled)
|
XCTAssertTrue(reset.isEnabled)
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking Reset drives the live `SCNView` — runs only on Metal
|
||||||
|
/// hosts, skipped on GPU-less runners so the same suite exercises
|
||||||
|
/// 3D once CI has a GPU (#147).
|
||||||
|
func testResetCameraInteractsWithScene() throws {
|
||||||
|
guard hasGPU else { throw XCTSkip("No Metal") }
|
||||||
|
openGamutSheet()
|
||||||
|
|
||||||
|
let reset = waitFor("btnResetGamutCamera")
|
||||||
reset.click()
|
reset.click()
|
||||||
|
|
||||||
|
closeGamutSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Settings sheet UI tests (issue #165).
|
||||||
|
///
|
||||||
|
/// The Verification thresholds once shared one non-wrapping `HStack` and
|
||||||
|
/// drew past the sheet's right clip on the macOS grouped `Form`. AX
|
||||||
|
/// existence cannot see clipping (#163), so containment is asserted on
|
||||||
|
/// real frame geometry against the sheet's bounds.
|
||||||
|
///
|
||||||
|
/// All three numeric fields also passed their default value as the
|
||||||
|
/// `TextField` label; inside an `HStack` row that label renders inline —
|
||||||
|
/// it is not a placeholder — producing "Stale after 30 [30] days". The
|
||||||
|
/// fields are now direct `Form` children, so the descriptive label
|
||||||
|
/// renders once in the label column and the box fills the control
|
||||||
|
/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it.
|
||||||
|
@MainActor
|
||||||
|
final class SettingsUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sheet content lives under `app.sheets`, outside the main window's
|
||||||
|
/// a11y tree (Milestone2 pattern).
|
||||||
|
private var sheet: XCUIElement {
|
||||||
|
app.sheets.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings() {
|
||||||
|
let gear = app.buttons["openSettingsBtn"]
|
||||||
|
XCTAssertTrue(gear.waitForExistence(timeout: 10))
|
||||||
|
gear.click()
|
||||||
|
XCTAssertTrue(sheet.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thresholdField(_ fieldID: String) -> XCUIElement {
|
||||||
|
let field = sheet.textFields[fieldID]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)")
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceFieldValue(_ field: XCUIElement, with text: String) {
|
||||||
|
field.click()
|
||||||
|
app.typeKey("a", modifierFlags: .command)
|
||||||
|
field.typeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSheetDismiss(timeout: TimeInterval = 10) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !sheet.exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(sheet.exists, "Expected sheet to disappear")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both ΔE rows must render fully inside the 560×620 sheet: the
|
||||||
|
/// label-column `StaticText`s and the control-column fields all sit
|
||||||
|
/// within the sheet bounds, the two fields share the Form's control
|
||||||
|
/// column margin, and the Warning row sits below the Good row so the
|
||||||
|
/// two cannot overlap on one clipped line.
|
||||||
|
///
|
||||||
|
/// The numeric fields are direct `Form` children, so macOS lifts
|
||||||
|
/// each `TextField` label into the right-aligned label column and
|
||||||
|
/// the editable box fills the control column — the same layout the
|
||||||
|
/// Pickers use. The control column ends only ~3.5 pt inside the
|
||||||
|
/// sheet (PopUpButtons reach it too), so the right-edge assertion is
|
||||||
|
/// "inside the sheet", not the older 12 pt compact-field inset.
|
||||||
|
func testVerificationRowsStayInsideSheet() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
let goodField = thresholdField("settingsDeltaEGood")
|
||||||
|
let warningField = thresholdField("settingsDeltaEWarning")
|
||||||
|
|
||||||
|
// Labels render as sibling staticTexts in the label column.
|
||||||
|
let goodLabel = sheet.staticTexts["Good ΔE ≤"]
|
||||||
|
let warningLabel = sheet.staticTexts["Warning ΔE ≤"]
|
||||||
|
XCTAssertTrue(goodLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(warningLabel.waitForExistence(timeout: 5))
|
||||||
|
|
||||||
|
// Left boundary: labels must be inside the sheet with >=12 pt inset
|
||||||
|
XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||||
|
XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||||
|
|
||||||
|
// Right boundary: fields must not draw past the sheet's clip
|
||||||
|
XCTAssertTrue(
|
||||||
|
goodField.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"Good ΔE field clips the sheet's right edge")
|
||||||
|
XCTAssertTrue(
|
||||||
|
warningField.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"Warning ΔE field clips the sheet's right edge")
|
||||||
|
|
||||||
|
// Vertical separation
|
||||||
|
XCTAssertTrue(
|
||||||
|
warningField.frame.minY > goodField.frame.minY,
|
||||||
|
"thresholds must be two separate rows")
|
||||||
|
|
||||||
|
// Both threshold fields align at the same control column margin
|
||||||
|
XCTAssertTrue(
|
||||||
|
abs(goodField.frame.minX - warningField.frame.minX) <= 1.0,
|
||||||
|
"Good and Warning ΔE fields should align at the same column margin")
|
||||||
|
|
||||||
|
// Other labels must not overflow the left boundary
|
||||||
|
let defaultInstLabel = sheet.staticTexts["Default instrument"]
|
||||||
|
XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(
|
||||||
|
defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||||
|
"Default instrument label must not overflow left edge")
|
||||||
|
|
||||||
|
let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"]
|
||||||
|
XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(
|
||||||
|
bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||||
|
"Bundled sidecars label must not overflow left edge")
|
||||||
|
|
||||||
|
sheet.buttons["Cancel"].click()
|
||||||
|
waitForSheetDismiss(timeout: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `warning <= good` fails `AppSettings.validate()` and keeps the
|
||||||
|
/// sheet open with the contract error text; restoring valid values
|
||||||
|
/// lets Save dismiss (issue #165 acceptance, strings are the #5
|
||||||
|
/// contract).
|
||||||
|
func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1")
|
||||||
|
sheet.buttons["Save"].click()
|
||||||
|
|
||||||
|
let error = sheet.staticTexts[
|
||||||
|
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||||
|
]
|
||||||
|
XCTAssertTrue(error.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet")
|
||||||
|
|
||||||
|
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5")
|
||||||
|
sheet.buttons["Save"].click()
|
||||||
|
waitForSheetDismiss(timeout: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// macOS renders a `TextField`'s first argument as a label, not a
|
||||||
|
/// placeholder — inside the old `HStack` rows it drew inline, so the
|
||||||
|
/// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
|
||||||
|
/// As direct `Form` children each label now renders exactly once, in
|
||||||
|
/// the label column; no `staticText` may echo the field's value.
|
||||||
|
func testNumericFieldsCarryLabelsNotDuplicatedValues() throws {
|
||||||
|
openSettings()
|
||||||
|
|
||||||
|
// (identifier, label-column text, rendered default value, the
|
||||||
|
// literal that used to double-render as the field's label)
|
||||||
|
// `value:` shows the formatted number — 2.0 renders as "2".
|
||||||
|
let rows: [(id: String, label: String, value: String, dup: String)] = [
|
||||||
|
("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"),
|
||||||
|
("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"),
|
||||||
|
("settingsCalStaleDays", "Stale after (days)", "30", "30"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for spec in rows {
|
||||||
|
let field = sheet.textFields[spec.id]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)")
|
||||||
|
XCTAssertEqual(
|
||||||
|
field.value as? String, spec.value,
|
||||||
|
"\(spec.id) default value changed unexpectedly")
|
||||||
|
XCTAssertTrue(
|
||||||
|
sheet.staticTexts[spec.label].waitForExistence(timeout: 5),
|
||||||
|
"\(spec.id) must render \"\(spec.label)\" once in the label column")
|
||||||
|
for ghost in Set([spec.value, spec.dup]) {
|
||||||
|
XCTAssertFalse(
|
||||||
|
sheet.staticTexts[ghost].exists,
|
||||||
|
"\(spec.id) must not render \"\(ghost)\" as a second label")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(
|
||||||
|
field.frame.maxX <= sheet.frame.maxX,
|
||||||
|
"\(spec.id) field clips the sheet's right edge")
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.buttons["Cancel"].click()
|
||||||
|
waitForSheetDismiss(timeout: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -695,7 +695,7 @@ Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bu
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
|
||||||
| `dispread` | Same |
|
| `dispread` | Same |
|
||||||
| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
| `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
|
||||||
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
|
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:6–14`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -725,6 +725,7 @@ The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot
|
|||||||
| `profcheck_{ti3_path}` | profcheck (full path) |
|
| `profcheck_{ti3_path}` | profcheck (full path) |
|
||||||
| `iccgamut_{stem}` | iccgamut |
|
| `iccgamut_{stem}` | iccgamut |
|
||||||
| `instlist` | instlist (literal) |
|
| `instlist` | instlist (literal) |
|
||||||
|
| `spotread` | spotread (literal, issue #148) |
|
||||||
| caller-supplied | unused `spawn_process` |
|
| caller-supplied | unused `spawn_process` |
|
||||||
|
|
||||||
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
|
||||||
@@ -830,6 +831,7 @@ From `lib.rs:54–119` plus the command bodies:
|
|||||||
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
|
||||||
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
|
||||||
| `detect_instruments` | instlist | `instlist` |
|
| `detect_instruments` | instlist | `instlist` |
|
||||||
|
| `run_spotread` | spotread (`-v -e [-c port] [-Y l]`, no `-u`) | `spotread` |
|
||||||
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
|
||||||
|
|
||||||
### Argyll runners (captured, no events)
|
### Argyll runners (captured, no events)
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents
|
|||||||
## No `"test_target"` fallbacks (#60)
|
## No `"test_target"` fallbacks (#60)
|
||||||
|
|
||||||
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
|
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
|
||||||
|
|
||||||
|
## Project file (#149)
|
||||||
|
|
||||||
|
A `.icceryproj` is a JSON **index** (`schema_version` 1) over `basename` + `cwd` + optional `media_recipe_id`/`preset_id`/`calibration_url`/`printer_*` + a `last_verification` ΔE₀₀ snapshot. It is never a second source of truth: opening one writes `WizardState` and re-runs the same artefact probe window focus uses — if JSON claims Stage 5 but disk stops at `.ti2`, the stepper follows the disk and the sidebar chip shows `projectChipStale` with an info banner.
|
||||||
|
|
||||||
|
`wizard_state.json` remains the crash-resume file; there is no auto-save. Recents live in `appDataDir/recent_projects.json` (cap 20, bookmark + path, corrupt → `[]` + keep bytes). `Save Report…` writes `{basename}-report.md` in cwd atomically. A live `CAL_` basename is never persisted — Save resolves the persisted `calibrationOriginalBasename` or refuses with a banner.
|
||||||
|
|||||||
@@ -80,3 +80,14 @@ If an unpatched binary rejects `-Y l`, capture last stderr line and expand Proce
|
|||||||
## Interactive buttons vs real keys
|
## Interactive buttons vs real keys
|
||||||
|
|
||||||
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
|
||||||
|
|
||||||
|
## Spot Read (issue #148)
|
||||||
|
|
||||||
|
The Spot Read sheet (`btnSpotRead` in the sidebar) runs the bundled
|
||||||
|
`spotread` sidecar under the single-lease process id `spotread` — it is
|
||||||
|
**not** Stage 3 and shares no identifiers or process ids with
|
||||||
|
`chartread`. Stage 3 is unchanged: `chartread_{basename}` remains the
|
||||||
|
only chart path. `spotread` argv is `-v -e [-c port] [-Y l]` — never
|
||||||
|
`-u` (the v2.0 `-u` policy covers printtarg + chartread + profcheck
|
||||||
|
only). Stdin reuses the `chartread` byte table (`" \n"` trigger,
|
||||||
|
`"q\n"` quit + ~500 ms + kill).
|
||||||
|
|||||||
@@ -827,3 +827,35 @@ HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`,
|
|||||||
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
|
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
|
||||||
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
|
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
|
||||||
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
|
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
|
||||||
|
| #147 compare + inspect | macOS native: layer toggles (`gamutLayer-*`), compare slot (one extra profile or `.gam`), click/typed-Lab containment, TIFF pixel sample. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. macOS rewrite notes (#28, #147)
|
||||||
|
|
||||||
|
The native viewer (`Sources/ICCery/GamutView.swift` + `GamutViewModel`) keeps
|
||||||
|
the v1 contract points that matter — `(a*, L*, b*)` axes, camera home
|
||||||
|
(180,120,180) lookAt (0,50,0), R resets via a local `NSEvent` monitor, native
|
||||||
|
faces only — and adds the compare/inspect layer model:
|
||||||
|
|
||||||
|
- **Layers.** `NamedGamut` (reference / profileA / profileB). sRGB cannot be
|
||||||
|
removed, only hidden. The compare slot holds exactly one profile; picking a
|
||||||
|
third replaces it and posts `gamutNoticeText`.
|
||||||
|
- **Toggles hide, not unload.** `visibleIDs` maps to `SCNNode.isHidden`; the
|
||||||
|
scene is built once and a checkbox never resets the camera.
|
||||||
|
- **Containment.** `GamutGeometry.containment` ray-casts the face table
|
||||||
|
(`GamutVertex.position` space) with off-axis retries on edge hits; no faces
|
||||||
|
→ `.unknown`. `GamutGeometry.volume` sums signed tetrahedra from the vertex
|
||||||
|
centroid (Lab-cubic); the `vol % of sRGB` clause only prints when both
|
||||||
|
volumes are finite and > 0.
|
||||||
|
- **Inspect.** Click a mesh (hit-test in the `SCNView` coordinator on
|
||||||
|
mouse-up, never a ZStack tap gesture), type Lab, or sample a TIFF pixel.
|
||||||
|
Per-layer in/out/? + swatch; `gamutInspectApprox` marks samples that went
|
||||||
|
through `ApproximateLab` (fixed-matrix sRGB→Lab D50, **not** ColorSync, no
|
||||||
|
CMM).
|
||||||
|
- **Fallback.** No GPU → `gamutViewerUnavailable` text replaces the scene;
|
||||||
|
layer toggles stay visible but disabled; the representable is never
|
||||||
|
respawned in a loop.
|
||||||
|
- **iccgamut** still runs only as the bundled sidecar, `-v -d {density}`
|
||||||
|
(density 10 = surface density, not a directory). Failure is an in-sheet
|
||||||
|
info notice — sRGB + profile A stay loaded (#24).
|
||||||
|
|||||||
+14
-7
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
|||||||
| Field | Default | Notes |
|
| Field | Default | Notes |
|
||||||
|-------|---------|-------|
|
|-------|---------|-------|
|
||||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||||
@@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS
|
|||||||
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
|
||||||
|
|
||||||
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
|
||||||
|
|
||||||
|
## Media library (`media_library.json`)
|
||||||
|
|
||||||
|
Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `id` | string | `recipe-<uuid>`, never user-typed |
|
||||||
|
| `name`, `notes` | string | Rendered through `Text` only (#114) |
|
||||||
|
| `printer_id` | string | CUPS queue id (`lpstat -e` name) |
|
||||||
|
| `printer_display_name` | string | Human label; applied to `wizard.printerName` |
|
||||||
|
| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags |
|
||||||
|
| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) |
|
||||||
|
| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset |
|
||||||
|
| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) |
|
||||||
|
| `calibration_url` | string? | Absolute `.cal` path, stored verbatim |
|
||||||
|
| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files |
|
||||||
|
| `created`, `updated` | iso8601 | |
|
||||||
|
|
||||||
|
Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`.
|
||||||
|
|
||||||
|
Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
|
|||||||
|
|
||||||
| File | Use |
|
| File | Use |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
|
||||||
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||||
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist.
|
|||||||
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
||||||
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
||||||
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
|
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`.
|
||||||
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|
||||||
### `BUILD-PLAN.md`
|
### `BUILD-PLAN.md`
|
||||||
@@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High`
|
|||||||
Milestone: M6
|
Milestone: M6
|
||||||
|
|
||||||
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
||||||
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||||
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
||||||
- Confirm entitlements: sandbox **false**.
|
- Confirm entitlements: sandbox **false**.
|
||||||
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no
|
|||||||
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
||||||
xcodegen generate --project .
|
xcodegen generate --project .
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
|
ARCHS="$(uname -m)" \
|
||||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ targets:
|
|||||||
- ICCery.entitlements
|
- ICCery.entitlements
|
||||||
- ICCery.Debug.entitlements
|
- ICCery.Debug.entitlements
|
||||||
- Argyll
|
- Argyll
|
||||||
|
- dmg-background.png
|
||||||
|
- dmg-background@2x.png
|
||||||
- path: Resources/Argyll
|
- path: Resources/Argyll
|
||||||
type: folder
|
type: folder
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case "$TAG" in
|
||||||
|
*prerelease*) PRERELEASE=true ;;
|
||||||
|
*) PRERELEASE=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
DMG="${1:-}"
|
DMG="${1:-}"
|
||||||
if [ -z "$DMG" ]; then
|
if [ -z "$DMG" ]; then
|
||||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||||
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
|||||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||||
|
|
||||||
if [ "$STATUS" = "404" ]; then
|
if [ "$STATUS" = "404" ]; then
|
||||||
echo "==> Creating release $TAG"
|
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST "$API/repos/$REPO/releases" \
|
-X POST "$API/repos/$REPO/releases" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||||
fi
|
fi
|
||||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# scripts/dmgbuild-settings.py
|
# scripts/dmgbuild-settings.py
|
||||||
#
|
#
|
||||||
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
|
# dmgbuild settings for ICCery. scripts/package-release.sh exports
|
||||||
# in the environment, or accept the defaults. Background art can be supplied
|
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
|
||||||
# later by placing a PNG at Resources/dmg-background.png and setting
|
# HiDPI TIFF). A missing background is a hard error — a grey
|
||||||
# DMG_BACKGROUND.
|
# Finder window is not an acceptable release artefact (#95).
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
|
|||||||
|
|
||||||
files = [app_path]
|
files = [app_path]
|
||||||
|
|
||||||
# Background art is optional. If the referenced PNG does not exist, fall back
|
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
|
||||||
# to a plain window. See docs/23-assets.md for the DMG background spec.
|
# about classic Alias Manager blobs. package-release.sh always passes
|
||||||
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
|
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
|
||||||
if background and not os.path.exists(background):
|
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
|
||||||
background = None
|
# have; 1.6.5 + TIFF is what CI can ship (#95).
|
||||||
|
background = os.environ.get('DMG_BACKGROUND', '')
|
||||||
|
if not background or not os.path.isfile(background):
|
||||||
|
sys.stderr.write(
|
||||||
|
'error: DMG_BACKGROUND must point at an existing image '
|
||||||
|
'(got %r)\n' % background)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
icon = None
|
icon = None
|
||||||
|
|
||||||
# Window size is enough for the app icon and the Applications alias.
|
# Window size is enough for the app icon and the Applications alias.
|
||||||
|
# Bitmap is slightly larger than this rect so title-bar chrome on
|
||||||
|
# 14+ does not crop the wordmark.
|
||||||
window_rect = ((100, 100), (660, 400))
|
window_rect = ((100, 100), (660, 400))
|
||||||
|
|
||||||
# Use icon view without extra chrome.
|
# Use icon view without extra chrome.
|
||||||
|
|||||||
@@ -2,19 +2,79 @@
|
|||||||
# scripts/ensure-host-tools.sh
|
# scripts/ensure-host-tools.sh
|
||||||
#
|
#
|
||||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
|
||||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
# current formula requires Xcode 15.3, which cannot be installed on
|
||||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
# macOS 12).
|
||||||
|
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
|
||||||
|
# build/.venv-dmgbuild so the test job never pip-installs it.
|
||||||
#
|
#
|
||||||
# Safe to run repeatedly: existing tools are left alone.
|
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
|
||||||
|
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
|
||||||
|
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
|
||||||
|
# offer 1.6.5 and keep a cached venv on that version (#95).
|
||||||
|
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly: existing tools are left alone unless the
|
||||||
|
# dmgbuild pin is not met.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
XCODEGEN_VERSION="2.38.0"
|
XCODEGEN_VERSION="2.38.0"
|
||||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||||
|
VENV="$ROOT/build/.venv-dmgbuild"
|
||||||
|
DMGBUILD_PIN="1.6.7"
|
||||||
|
|
||||||
echo "==> Ensuring dmgbuild"
|
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
|
||||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dmgbuild) INSTALL_DMGBUILD=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$INSTALL_DMGBUILD" = "1" ]; then
|
||||||
|
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
|
||||||
|
mkdir -p "$ROOT/build"
|
||||||
|
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
|
||||||
|
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
|
||||||
|
# the next pip invocation crashed. Recreate if pip is already dead.
|
||||||
|
if [ -x "$VENV/bin/python" ] \
|
||||||
|
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||||
|
echo "==> venv pip is broken; recreating $VENV"
|
||||||
|
rm -rf "$VENV"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
fi
|
||||||
|
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
|
||||||
|
PIP_IGNORE_REQUIRES_PYTHON=1
|
||||||
|
export PIP_IGNORE_REQUIRES_PYTHON
|
||||||
|
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
|
||||||
|
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
|
||||||
|
"dmgbuild==$DMGBUILD_PIN" \
|
||||||
|
'ds_store>=1.3.3' \
|
||||||
|
'mac_alias>=2.2.3'
|
||||||
|
"$VENV/bin/python" -c 'from importlib.metadata import version
|
||||||
|
print("dmgbuild", version("dmgbuild"))
|
||||||
|
print("ds_store", version("ds_store"))
|
||||||
|
print("mac_alias", version("mac_alias"))
|
||||||
|
parts=[]
|
||||||
|
for p in version("dmgbuild").split("."):
|
||||||
|
try:
|
||||||
|
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
|
||||||
|
except ValueError:
|
||||||
|
parts.append(0)
|
||||||
|
parts += [0, 0, 0]
|
||||||
|
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
|
||||||
|
'
|
||||||
|
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||||
|
echo "$VENV/bin" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
PATH="$VENV/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
else
|
||||||
|
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v xcodegen >/dev/null 2>&1; then
|
if command -v xcodegen >/dev/null 2>&1; then
|
||||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||||
|
|||||||
@@ -106,9 +106,19 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
cp -R "$BIN_DIR"/. "$DEST"/
|
cp -R "$BIN_DIR"/. "$DEST"/
|
||||||
|
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
|
||||||
|
VENDOR_ROOT="$ROOT/Vendor/Argyll"
|
||||||
|
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
|
||||||
|
if [ -f "$license_src" ]; then
|
||||||
|
cp "$license_src" "$VENDOR_ROOT/License.txt"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
find "$DEST" -type f -exec chmod 0755 {} +
|
find "$DEST" -type f -exec chmod 0755 {} +
|
||||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||||
|
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
|
||||||
|
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
|
||||||
|
|
||||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||||
|
|||||||
@@ -103,19 +103,39 @@ EOF
|
|||||||
scripts/verify-sidecar-signatures.sh "$APP"
|
scripts/verify-sidecar-signatures.sh "$APP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing / locating dmgbuild"
|
echo "==> Locating dmgbuild"
|
||||||
|
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
|
||||||
|
# which created build/.venv-dmgbuild and prepended it to PATH. Local
|
||||||
|
# runs bootstrap the same venv if dmgbuild is missing.
|
||||||
|
VENV="$ROOT/build/.venv-dmgbuild"
|
||||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||||
VENV="$ROOT/build/.venv-dmgbuild"
|
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||||
if [ ! -d "$VENV/bin" ]; then
|
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||||
python3 -m venv "$VENV"
|
|
||||||
"$VENV/bin/pip" install --upgrade pip
|
|
||||||
"$VENV/bin/pip" install dmgbuild
|
|
||||||
fi
|
fi
|
||||||
PATH="$VENV/bin:$PATH"
|
PATH="$VENV/bin:$PATH"
|
||||||
export PATH
|
export PATH
|
||||||
fi
|
fi
|
||||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||||
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
|
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dmgbuild $(command -v dmgbuild)"
|
||||||
|
if [ -x "$VENV/bin/python" ]; then
|
||||||
|
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
|
||||||
|
fi
|
||||||
|
|
||||||
|
PNG1X="$ROOT/Resources/dmg-background.png"
|
||||||
|
PNG2X="$ROOT/Resources/dmg-background@2x.png"
|
||||||
|
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
|
||||||
|
echo "error: missing $PNG1X or $PNG2X" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$ROOT/build"
|
||||||
|
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
|
||||||
|
echo "==> Building HiDPI DMG background TIFF"
|
||||||
|
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
|
||||||
|
if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||||
|
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
|
|||||||
DMG_APP="$APP" \
|
DMG_APP="$APP" \
|
||||||
DMG_FILENAME="$DMG" \
|
DMG_FILENAME="$DMG" \
|
||||||
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
||||||
|
DMG_BACKGROUND="$DMG_BACKGROUND" \
|
||||||
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
||||||
|
|
||||||
echo "DMG: $PWD/$DMG"
|
echo "DMG: $PWD/$DMG"
|
||||||
|
|||||||
Reference in New Issue
Block a user