Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efeefb8cea | ||
|
|
7fc38aabbb | ||
|
|
972e166430 | ||
|
|
ecbf46723b | ||
|
|
bd14f30a05 | ||
|
|
12061a03ac | ||
|
|
f7f6a8bd87 | ||
|
|
ed91ebf807 | ||
|
|
77c065f433 | ||
|
|
f7099a9808 | ||
|
|
ddb9245f44 | ||
|
|
226e7b2a78 | ||
|
|
2407abfd9e | ||
|
|
356ae0bfa9 | ||
|
|
60f3aa3991 | ||
|
|
b89d9777ad | ||
|
|
7a3a0886be | ||
|
|
5389e4c2bd | ||
|
|
972368aa85 | ||
|
|
4f3caf335d | ||
|
|
de142a9690 | ||
|
|
a2f994821f | ||
|
|
1d6032456d | ||
|
|
fc1530ab2e | ||
|
|
ffbe56f43a | ||
|
|
ee5da4a637 | ||
|
|
0513b272cb | ||
|
|
555bf8fda4 | ||
|
|
f3c7b19694 | ||
|
|
5e696ff3e9 | ||
|
|
19f619ad98 | ||
|
|
09f466ae46 | ||
|
|
d228594bd3 | ||
|
|
6adbf99bd9 | ||
|
|
d8c41444b9 | ||
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 | ||
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 | ||
|
|
62a45d9c7d | ||
|
|
bb3cc3c858 | ||
|
|
eb73bf0106 | ||
|
|
736fd080cb | ||
|
|
14cd544e05 | ||
|
|
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 | ||
|
|
607a4e0df4 | ||
|
|
67199877a2 | ||
|
|
9a6da4648f | ||
|
|
b5250591ea | ||
|
|
c1ec07f726 | ||
|
|
a975f1a4e4 | ||
|
|
7c2282a7dd | ||
|
|
caeafb22be | ||
|
|
aac4f10d23 | ||
|
|
b7ac4f92ed | ||
|
|
3244efd366 | ||
|
|
7a05da3a35 | ||
|
|
5134e82e63 | ||
|
|
7fd93247cb | ||
|
|
a0fcede454 | ||
|
|
97eafd11fb | ||
|
|
ed7487c2b6 | ||
|
|
a2e3f11e70 | ||
|
|
c3c9bbc5ba | ||
|
|
b5683aa36a | ||
|
|
707455dcfb | ||
|
|
17ee5d6717 | ||
|
|
2295bcdbae | ||
|
|
c9bff50a1a | ||
|
|
1b3d3dd821 | ||
|
|
55dee0f0cd | ||
|
|
45fd2b988f | ||
|
|
7c6ad1e6ce | ||
|
|
9857ddb4d0 | ||
|
|
7a6b82816b | ||
|
|
32d2184d2e | ||
|
|
a76120d9f7 | ||
|
|
a30a8fc551 | ||
|
|
83f3a4f0e2 | ||
|
|
150d094632 | ||
|
|
3184c50fb9 | ||
|
|
b2b85ae835 | ||
|
|
09f433106d | ||
|
|
891a504ee7 | ||
|
|
f681e60778 | ||
|
|
0d0233e8d6 | ||
|
|
d117d7a510 | ||
|
|
78ffeff61b | ||
|
|
0298f69a2c | ||
|
|
da602e2775 | ||
|
|
5e3b183b9a | ||
|
|
e48c3f6840 | ||
|
|
7751701208 | ||
|
|
c539507d5d | ||
|
|
0ffcf5ea91 | ||
|
|
597cce7b60 | ||
|
|
bb4512e129 | ||
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 | ||
|
|
85fa543c4a | ||
|
|
6a612c0c1d | ||
|
|
e203127794 | ||
|
|
118b77b441 | ||
|
|
0115aa2726 | ||
|
|
e9daaddf2d | ||
|
|
d4261ba2c9 | ||
|
|
2e8b07c8f3 | ||
|
|
03d6558708 | ||
|
|
e0012dc344 | ||
|
|
e424eefa5a | ||
|
|
63ec0bde63 | ||
|
|
6309f9fd12 | ||
|
|
43d35515e1 | ||
|
|
a110eb81e6 | ||
|
|
d40f0052d2 | ||
|
|
80408dd682 | ||
|
|
2ffcabbd52 | ||
|
|
85f0de631c | ||
|
|
65457b903e | ||
|
|
62eb5730a9 | ||
|
|
5379b4a94f | ||
|
|
a2243be298 | ||
|
|
f7ba49d3d5 | ||
|
|
0122d92f2b | ||
|
|
d11232813b | ||
|
|
3b4e61ef02 | ||
|
|
aed415bcb5 | ||
|
|
288e534d4d | ||
|
|
906a449062 | ||
|
|
6c939289ea | ||
|
|
bcac74fa6c | ||
|
|
77fd8937f0 | ||
|
|
2e4bca3f9d | ||
|
|
ce077d11a0 | ||
|
|
58eacec2e4 | ||
|
|
2bdd6b0291 | ||
|
|
c118e42768 | ||
|
|
8a188043e7 | ||
|
|
3e7706f689 | ||
|
|
8f43a93b64 | ||
|
|
f940ffd5a0 | ||
|
|
55ffe879ac | ||
|
|
120f713b46 | ||
|
|
f47b642c02 | ||
|
|
87e4f4fa8e | ||
|
|
c5fc89ec40 | ||
|
|
b52720700f | ||
|
|
f027693fa9 | ||
|
|
b2ef86422e | ||
|
|
7cf0b4b571 | ||
|
|
bc5c0381c9 | ||
|
|
196c51d1dd | ||
|
|
18767c2212 | ||
|
|
1d9c6ae334 | ||
|
|
64e9776a63 | ||
|
|
28ae01af85 | ||
|
|
bb96c66008 | ||
|
|
9a50057845 | ||
|
|
6f4491d0e9 | ||
|
|
ff883a43b2 | ||
|
|
34bef9a78e | ||
|
|
e929576c31 | ||
|
|
0ee609be1c | ||
|
|
d37dcedfd9 | ||
|
|
a73c6c0b97 | ||
|
|
563f0e5d4a | ||
|
|
cd4665e7a9 | ||
|
|
153b6194a3 | ||
|
|
f78da50a59 | ||
|
|
629a1fce1d | ||
|
|
460b0a1ffa | ||
|
|
ef56cdd7d4 | ||
|
|
61c6d62ee2 | ||
|
|
f769cf7fec | ||
|
|
da53feec7f | ||
|
|
64393b7591 | ||
|
|
cc184bfff3 | ||
|
|
bb6ca957ba | ||
|
|
ad6f8247d2 | ||
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 | ||
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
71172d751a |
@@ -0,0 +1,259 @@
|
|||||||
|
name: macOS CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
|
||||||
|
# macos-14 works for this pipeline.
|
||||||
|
runs-on: macos-12
|
||||||
|
env:
|
||||||
|
DERIVED: build/DerivedData-test
|
||||||
|
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||||
|
# only honours values matching 'v[0-9]*'.
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
# Stamp the test build with the same version triple packaging uses
|
||||||
|
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||||
|
eval "$(scripts/version.sh)"
|
||||||
|
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||||
|
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='-' \
|
||||||
|
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||||
|
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||||
|
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||||
|
|
||||||
|
# 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. The self-hosted Mac intermittently times out enabling
|
||||||
|
# that mode (run 29700) or launches the app into
|
||||||
|
# `.runningBackground` without ever activating it (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: Prepare Node CA bundle (failure path)
|
||||||
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
run: |
|
||||||
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
|
security find-certificate -a -p \
|
||||||
|
/System/Library/Keychains/SystemRootCertificates.keychain \
|
||||||
|
/Library/Keychains/System.keychain \
|
||||||
|
> "$NODE_CA_FILE" 2>/dev/null || true
|
||||||
|
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
|
||||||
|
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload UI test xcresult
|
||||||
|
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
env:
|
||||||
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
|
with:
|
||||||
|
name: ui-test-xcresult
|
||||||
|
path: build/DerivedData-test/Logs/Test
|
||||||
|
|
||||||
|
package:
|
||||||
|
needs: build-and-test
|
||||||
|
runs-on: macos-12
|
||||||
|
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
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: Prepare Node CA bundle
|
||||||
|
run: |
|
||||||
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
|
security find-certificate -a -p \
|
||||||
|
/System/Library/Keychains/SystemRootCertificates.keychain \
|
||||||
|
/Library/Keychains/System.keychain \
|
||||||
|
> "$NODE_CA_FILE" 2>/dev/null || true
|
||||||
|
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
|
||||||
|
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload DMG artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
env:
|
||||||
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
|
with:
|
||||||
|
name: iccery-dmg
|
||||||
|
path: ICCery-*.dmg
|
||||||
|
|
||||||
|
- name: Attach DMG to Gitea release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: scripts/attach-release-asset.sh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# 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
|
||||||
|
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
|
||||||
|
# only honours values matching 'v[0-9]*'.
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
# Stamp the test build with the same version triple packaging uses
|
||||||
|
# so CI-built apps are traceable (and mis-tags fail early) (#189).
|
||||||
|
eval "$(scripts/version.sh)"
|
||||||
|
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||||
|
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='-' \
|
||||||
|
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||||
|
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||||
|
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
with:
|
||||||
|
# Full history + tags: scripts/version.sh needs `git describe` and
|
||||||
|
# `git rev-list --count`; the default depth-1 clone has neither.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
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
|
||||||
@@ -18,5 +18,17 @@ Vendor/Argyll/
|
|||||||
# XcodeGen output (regenerate with `make gen`)
|
# XcodeGen output (regenerate with `make gen`)
|
||||||
ICCery.xcodeproj/
|
ICCery.xcodeproj/
|
||||||
|
|
||||||
|
# Local-only developer helpers (never committed)
|
||||||
|
/set-version.sh
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Release artefacts (not git blobs)
|
||||||
|
*.dmg
|
||||||
|
*.zip
|
||||||
|
Release/
|
||||||
|
notarization/
|
||||||
|
build/
|
||||||
|
docs/megaplans/*
|
||||||
|
docs/megaplans
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5
|
|||||||
- No Tauri, no Rust host, no WKWebView, no Three.js.
|
- No Tauri, no Rust host, no WKWebView, no Three.js.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
- `ICCery` — app target (SwiftUI shell).
|
- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in
|
||||||
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel).
|
`Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`,
|
||||||
|
`TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`),
|
||||||
|
`TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation`
|
||||||
|
lives here, never in `ICCeryCore`).
|
||||||
|
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only).
|
||||||
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
|
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
|
||||||
|
|
||||||
## AGPL boundary
|
## AGPL boundary
|
||||||
@@ -39,17 +43,124 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
|||||||
User-supplied strings via SwiftUI `Text` only (#114).
|
User-supplied strings via SwiftUI `Text` only (#114).
|
||||||
TIFF never rendered directly — host-side PNG preview (#58).
|
TIFF never rendered directly — host-side PNG preview (#58).
|
||||||
|
|
||||||
|
## Print spool — native since v2.0 (#201); v1 `lp` path eradicated
|
||||||
|
Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler`
|
||||||
|
(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver`
|
||||||
|
(Stage 2 always wins, D6), draw 1:1 with interpolation off.
|
||||||
|
`lp` is eradicated from the target-print path (historical v1: `LpArgs`,
|
||||||
|
`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted).
|
||||||
|
`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities,
|
||||||
|
media/quality/bypass key detection and the Stage 2 mirror.
|
||||||
|
UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler`
|
||||||
|
appends one resolved-ticket line per page (D8).
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
|
||||||
|
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
|
||||||
|
`CURRENT_PROJECT_VERSION` = `git rev-list --count HEAD` (#189).
|
||||||
|
`v*` tag builds hard-fail if tag's X.Y.Z ≠ `project.yml` MARKETING_VERSION —
|
||||||
|
bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
|
||||||
|
|
||||||
## Branching
|
## Branching
|
||||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
|
|
||||||
|
## Issue ticket style
|
||||||
|
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
|
||||||
|
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
|
||||||
|
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
|
||||||
|
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
|
||||||
|
belongs to an active `mN` milestone.
|
||||||
|
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
|
||||||
|
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
|
||||||
|
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
|
||||||
|
`## Dependencies` → `## References`.
|
||||||
|
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
|
||||||
|
concrete implementation plan.
|
||||||
|
- Dependencies/blockers must **always** be recorded via the gitea MCP
|
||||||
|
`issue_write` methods (`add_dependency`, `block_issue`; reads via
|
||||||
|
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
|
||||||
|
dependencies"), not just mentioned in the body. This is
|
||||||
|
mandatory when issues share a milestone with an implementation order:
|
||||||
|
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
|
||||||
|
the order is machine-readable. The `## Dependencies` body section may
|
||||||
|
still summarise them for readability, but the MCP links are authoritative.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
## Remote test builds (runner host)
|
||||||
|
**Policy: all builds and test runs that _can_ execute on the remote host
|
||||||
|
_should_ execute there.** Use `localadmin@192.168.0.172` whenever the
|
||||||
|
Gitea `macos_12` runner is not actively executing a workflow job.
|
||||||
|
|
||||||
|
Test builds done as part of change implementations run on the CI runner
|
||||||
|
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
|
||||||
|
with Xcode 14.2, and CI failures there are often environmental and do not
|
||||||
|
reproduce locally.
|
||||||
|
|
||||||
|
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
|
||||||
|
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
|
||||||
|
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
|
||||||
|
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
|
||||||
|
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
|
||||||
|
environment.
|
||||||
|
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
|
||||||
|
the `SSHPASS` env var — never on the command line:
|
||||||
|
```sh
|
||||||
|
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
|
||||||
|
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
|
||||||
|
```
|
||||||
|
First connection needs `-o StrictHostKeyChecking=accept-new`.
|
||||||
|
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
|
||||||
|
full history needed by `scripts/version.sh`). Before testing, sync it to
|
||||||
|
the same base commit as the local work:
|
||||||
|
`git fetch origin && git checkout <local base sha>`, then `scp` each
|
||||||
|
locally-changed file over its remote counterpart (uncommitted work
|
||||||
|
transfers this way — there is no push).
|
||||||
|
- `xcodegen` is **not** on PATH: prepend
|
||||||
|
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
|
||||||
|
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
|
||||||
|
impossible on macOS 12 — #109).
|
||||||
|
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
|
||||||
|
`DERIVED=build/DerivedData-test`:
|
||||||
|
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
|
||||||
|
→ `eval "$(scripts/version.sh)"` → `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='-' MARKETING_VERSION=…
|
||||||
|
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
|
||||||
|
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
|
||||||
|
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
|
||||||
|
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
|
||||||
|
-derivedDataPath "$DERIVED"`.
|
||||||
|
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
|
||||||
|
GUI session and fail environmentally on the VM (runs 29700, 29804,
|
||||||
|
42183) — do not use them for verification unless asked.
|
||||||
|
- xcodebuild takes minutes — run the ssh command in a background shell
|
||||||
|
and poll rather than blocking.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
One spool path remains (#201 D2): write **both** vocabularies on the native
|
||||||
|
path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` =
|
||||||
|
`AP_ApplicationColorMatching`) **and** the Quartz dictionary
|
||||||
|
(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`,
|
||||||
|
legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested
|
||||||
|
`com.apple.print.printSettings` mirror).
|
||||||
|
|
||||||
|
## Gitea issue dependencies
|
||||||
|
Use the `gitea` MCP (custom build with blocking support — verified working):
|
||||||
|
|
||||||
|
- `issue_write` methods:
|
||||||
|
- `add_dependency` — `blocking_issue` blocks `issue_number`.
|
||||||
|
- `remove_dependency` — removes `blocking_issue` from `issue_number`'s blockers.
|
||||||
|
- `block_issue` / `unblock_issue` — `issue_number` blocks/unblocks `blocked_issue`.
|
||||||
|
- `issue_read` methods: `list_dependencies` (issues blocking N),
|
||||||
|
`list_blocks` (issues N blocks).
|
||||||
|
- All issue numbers are *display numbers*, not db ids.
|
||||||
|
|||||||
@@ -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) + M11 (id 34) + 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.
|
||||||
@@ -16,9 +16,16 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
||||||
| 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 |
|
||||||
|
| 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` |
|
||||||
|
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
|
||||||
| 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–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
|
||||||
|
|
||||||
|
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## PROP-M6-MEGAPLAN
|
||||||
|
Status: PENDING
|
||||||
|
Intent: Produce the M6 milestone planning deliverables (M6-MEGAPLAN.md, M6-BRANCH-MAP.md, M6-RISK-REGISTER.md) per the M6_MEGAPLAN_PROMPT.md baseline.
|
||||||
|
|
||||||
|
## PROP-M6-MEGAPLAN-REV1
|
||||||
|
Status: PENDING
|
||||||
|
Intent: Revise M6 megaplan per review (unstack #28, pre-split #29/#30, name CGATS open-panel test, sRGB.gam gate, Phase 0 spot-check).
|
||||||
|
|
||||||
|
## PROP-M7-MEGAPLAN
|
||||||
|
Status: PENDING
|
||||||
|
Intent: Plan M7 UAT-ready hardening + repo cleanup. No product code in this step. #16 out.
|
||||||
|
|
||||||
|
## PROP-M7-CI-RUNNER-SIGN-FIX
|
||||||
|
Status: PENDING
|
||||||
|
Intent: Correct the M7 U1 CI signing block so `*Runner.app` is not manually re-signed with `codesign --options runtime`; preserves the `xctrunner` entitlements and allows `ICCeryUITests` to boot.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 5.7
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "ICCeryCore",
|
name: "ICCeryCore",
|
||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v12)],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum —
|
||||||
|
/// `-d` / `-u` / `-r` still mean different things per binary.
|
||||||
|
public enum ArgsBuilder {
|
||||||
|
/// `["-f", value]` when `value` is non-nil.
|
||||||
|
public static func option(_ flag: String, _ value: String?) -> [String] {
|
||||||
|
guard let value else { return [] }
|
||||||
|
return [flag, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `["-f", trimmed]` when trimmed is non-empty.
|
||||||
|
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
|
||||||
|
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!raw.isEmpty else { return [] }
|
||||||
|
return [flag, raw]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
|
||||||
|
public static func optionUnlessApprox(
|
||||||
|
_ flag: String,
|
||||||
|
_ value: Double?,
|
||||||
|
skip: Double,
|
||||||
|
epsilon: Double = 0.001,
|
||||||
|
format: String = "%.2f"
|
||||||
|
) -> [String] {
|
||||||
|
guard let value, abs(value - skip) >= epsilon else { return [] }
|
||||||
|
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bare flag when `when` is true.
|
||||||
|
public static func flag(_ flag: String, when: Bool) -> [String] {
|
||||||
|
when ? [flag] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,44 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Errors from `ArgyllRunner` executions.
|
/// Errors from `ArgyllRunner` executions.
|
||||||
public enum ArgyllRunnerError: LocalizedError, Equatable {
|
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||||
case processFailed(code: Int32, logs: [String])
|
case toolFailed(tool: String, code: Int32, logs: [String])
|
||||||
case missingArtefact(String)
|
case missingArtefact(String)
|
||||||
case malformedManifest(String)
|
case malformedManifest(String)
|
||||||
|
case instrumentDetectionFailed(String)
|
||||||
|
case profcheckUnparseable
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .processFailed(let code, _):
|
case .toolFailed(let tool, let code, let logs):
|
||||||
|
let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
?? "exited with code \(code)"
|
||||||
|
switch tool {
|
||||||
|
case "chartread":
|
||||||
|
return "Chartread failed: \(detail)"
|
||||||
|
case "average":
|
||||||
|
return "Averaging failed: \(detail)"
|
||||||
|
case "colprof":
|
||||||
|
return "Profile creation failed: \(detail)"
|
||||||
|
case "printcal":
|
||||||
|
return "Calibration curve computation failed: \(detail)"
|
||||||
|
case "applycal":
|
||||||
|
return "Apply calibration failed: \(detail)"
|
||||||
|
case "iccgamut":
|
||||||
|
return "Gamut extraction failed: \(detail)"
|
||||||
|
case "profcheck":
|
||||||
|
return "Profile verification failed: \(detail)"
|
||||||
|
default:
|
||||||
return "Process exited with code \(code)"
|
return "Process exited with code \(code)"
|
||||||
|
}
|
||||||
case .missingArtefact(let path):
|
case .missingArtefact(let path):
|
||||||
return "Expected output file was not created: \(path)"
|
return "Expected output file was not created: \(path)"
|
||||||
case .malformedManifest(let reason):
|
case .malformedManifest(let reason):
|
||||||
return "Failed to parse printtarg manifest: \(reason)"
|
return "Failed to parse printtarg manifest: \(reason)"
|
||||||
|
case .instrumentDetectionFailed(let reason):
|
||||||
|
return "Instrument detection failed: \(reason)"
|
||||||
|
case .profcheckUnparseable:
|
||||||
|
return "Profile verification produced unparseable output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +74,48 @@ public struct ArgyllRunner: Sendable {
|
|||||||
self.binaryResolver = binaryResolver
|
self.binaryResolver = binaryResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared streaming loop (issue #79)
|
||||||
|
|
||||||
|
private func runStreamingTool(
|
||||||
|
name: String,
|
||||||
|
id: String,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL?,
|
||||||
|
flushPartialLines: Bool = false,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> CollectedRun {
|
||||||
|
let binaryURL = binaryResolver.resolve(name)
|
||||||
|
await ensureNotRunning(id: id)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: id,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: arguments,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
let run = await collect(
|
||||||
|
id: id,
|
||||||
|
events: events,
|
||||||
|
onLogBatch: onLogBatch,
|
||||||
|
flushPartialLines: flushPartialLines
|
||||||
|
)
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: name,
|
||||||
|
code: run.exitCode ?? -1,
|
||||||
|
logs: run.lines
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireArtefact(_ url: URL) throws -> URL {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(url.path)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - targen (Stage 1)
|
// MARK: - targen (Stage 1)
|
||||||
|
|
||||||
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||||
@@ -60,26 +127,16 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try TargenArgs.build(config: config)
|
let args = try TargenArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("targen")
|
|
||||||
let processId = ProcessID.targen(cleanBasename)
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
let events = processManager.events()
|
name: "targen",
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
|
||||||
}
|
|
||||||
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
return try requireArtefact(ti1URL)
|
||||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
|
||||||
}
|
|
||||||
return ti1URL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - printtarg (Stage 2)
|
// MARK: - printtarg (Stage 2)
|
||||||
@@ -94,25 +151,15 @@ public struct ArgyllRunner: Sendable {
|
|||||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
let args = try PrinttargArgs.build(config: config)
|
let args = try PrinttargArgs.build(config: config)
|
||||||
let binaryURL = binaryResolver.resolve("printtarg")
|
|
||||||
let processId = ProcessID.printtarg(cleanBasename)
|
let processId = ProcessID.printtarg(cleanBasename)
|
||||||
|
let run = try await runStreamingTool(
|
||||||
let events = processManager.events()
|
name: "printtarg",
|
||||||
try await processManager.runStreaming(
|
|
||||||
id: processId,
|
id: processId,
|
||||||
binary: binaryURL,
|
|
||||||
arguments: args,
|
arguments: args,
|
||||||
workingDirectory: cwd
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
)
|
)
|
||||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
|
||||||
|
|
||||||
guard run.exitCode == 0 else {
|
|
||||||
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
|
||||||
}
|
|
||||||
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
|
||||||
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
|
||||||
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest: PrinttargManifest
|
let manifest: PrinttargManifest
|
||||||
do {
|
do {
|
||||||
@@ -145,22 +192,54 @@ 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
|
||||||
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
|
private func ensureNotRunning(id: String) async {
|
||||||
|
guard await processManager.isRunning(id) else { return }
|
||||||
|
await processManager.kill(id: id)
|
||||||
|
var attempts = 0
|
||||||
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||||
|
attempts += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct CollectedRun {
|
private struct CollectedRun {
|
||||||
var exitCode: Int32?
|
var exitCode: Int32?
|
||||||
var stdout: String
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
var lines: [String]
|
var lines: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drains the event stream until this child's `exit` event.
|
/// Drains the event stream until this child's `exit` event.
|
||||||
/// stdout is accumulated both per-line (logs) and verbatim (for
|
/// stdout is accumulated both per-line (logs) and verbatim (for
|
||||||
/// the manifest parse — the pretty JSON needs its newlines).
|
/// the manifest parse — the pretty JSON needs its newlines).
|
||||||
|
///
|
||||||
|
/// When `flushPartialLines` is `true`, a background `Task` flushes
|
||||||
|
/// unterminated output every 500 ms so tools like `colprof` that
|
||||||
|
/// print dots without newlines still produce log batches.
|
||||||
private func collect(
|
private func collect(
|
||||||
id processId: String,
|
id processId: String,
|
||||||
events: AsyncStream<ProcessEvent>,
|
events: AsyncStream<ProcessEvent>,
|
||||||
onLogBatch: (@Sendable ([String]) -> Void)?
|
onLogBatch: (@Sendable ([String]) -> Void)?,
|
||||||
|
flushPartialLines: Bool = false
|
||||||
) async -> CollectedRun {
|
) async -> CollectedRun {
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
var stdout = ""
|
var stdout = ""
|
||||||
|
var stderr = ""
|
||||||
var pendingBatch: [String] = []
|
var pendingBatch: [String] = []
|
||||||
var exitCode: Int32?
|
var exitCode: Int32?
|
||||||
var lastFlush = Date()
|
var lastFlush = Date()
|
||||||
@@ -172,6 +251,17 @@ public struct ArgyllRunner: Sendable {
|
|||||||
onLogBatch?(out)
|
onLogBatch?(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dotFlushTask: Task<Void, Never>?
|
||||||
|
if flushPartialLines {
|
||||||
|
dotFlushTask = Task { [processManager] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await processManager.flushPartialLine(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for await event in events {
|
for await event in events {
|
||||||
guard event.id == processId else { continue }
|
guard event.id == processId else { continue }
|
||||||
switch event {
|
switch event {
|
||||||
@@ -181,6 +271,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
pendingBatch.append(line)
|
pendingBatch.append(line)
|
||||||
case .stderr(_, let line):
|
case .stderr(_, let line):
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
|
stderr += line + "\n"
|
||||||
pendingBatch.append(line)
|
pendingBatch.append(line)
|
||||||
case .error(_, let message):
|
case .error(_, let message):
|
||||||
lines.append("Error: \(message)")
|
lines.append("Error: \(message)")
|
||||||
@@ -202,6 +293,717 @@ public struct ArgyllRunner: Sendable {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
|
|
||||||
|
dotFlushTask?.cancel()
|
||||||
|
if let dotFlushTask {
|
||||||
|
_ = await dotFlushTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - instlist (Stage 3 detection)
|
||||||
|
|
||||||
|
/// Runs `instlist` and returns the detected devices.
|
||||||
|
///
|
||||||
|
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
|
||||||
|
/// fallback constrained to known instrument tokens is used.
|
||||||
|
public func detectInstruments() async throws -> [InstrumentDevice] {
|
||||||
|
let binaryURL = binaryResolver.resolve("instlist")
|
||||||
|
let processId = ProcessID.instlist
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: [],
|
||||||
|
workingDirectory: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
var accumulator = JSONAccumulator()
|
||||||
|
var stdout = ""
|
||||||
|
var stderr: [String] = []
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
stdout += line + "\n"
|
||||||
|
_ = accumulator.feed(line: line)
|
||||||
|
case .stderr(_, let line):
|
||||||
|
stderr.append(line)
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if exitCode != nil { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
|
||||||
|
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let code = exitCode, code != 0, stderr.isEmpty == false {
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: try to parse the raw stdout as a text document.
|
||||||
|
if let devices = try? InstrumentParser.parse(stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - average (Stage 3 multi-pass finish)
|
||||||
|
|
||||||
|
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
|
||||||
|
public func runAverage(
|
||||||
|
config: AverageConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try AverageArgs.build(config: config)
|
||||||
|
let processId = ProcessID.average(config.basename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
|
name: "average",
|
||||||
|
id: processId,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
|
)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||||
|
return try requireArtefact(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - colprof (Stage 4)
|
||||||
|
|
||||||
|
/// Runs `colprof` streaming, collecting logs and classifying progress
|
||||||
|
/// until the profile is written.
|
||||||
|
public func runColprof(
|
||||||
|
config: ColprofConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
let processId = ProcessID.colprof(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
|
name: "colprof",
|
||||||
|
id: processId,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
flushPartialLines: true,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
|
)
|
||||||
|
|
||||||
|
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||||
|
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||||
|
guard let profileURL = ArtefactProbe.resolveProfile(
|
||||||
|
basename: cleanBasename,
|
||||||
|
cwd: cwd
|
||||||
|
) else {
|
||||||
|
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
|
||||||
|
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
|
||||||
|
}
|
||||||
|
return profileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - applycal (post-colprof calibration curve)
|
||||||
|
|
||||||
|
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
|
||||||
|
///
|
||||||
|
/// Runs `applycal` captured and performs an in-place replace via
|
||||||
|
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
|
||||||
|
/// file is removed and the original is left untouched. The UI must
|
||||||
|
/// never request `unapply` (#52).
|
||||||
|
public func runApplycal(
|
||||||
|
config: ApplycalConfig
|
||||||
|
) async throws -> URL {
|
||||||
|
assert(!config.unapply, "runApplycal does not support unapply")
|
||||||
|
|
||||||
|
let inputURL = config.inputProfileURL
|
||||||
|
let cwd = inputURL.deletingLastPathComponent()
|
||||||
|
let binaryURL = binaryResolver.resolve("applycal")
|
||||||
|
let processId = ProcessID.applycal(inputURL.lastPathComponent)
|
||||||
|
|
||||||
|
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
|
||||||
|
let fm = FileManager.default
|
||||||
|
|
||||||
|
// Remove any stale tmp from a previous crash.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
|
||||||
|
let outputConfig = ApplycalConfig(
|
||||||
|
calibrationPath: config.calibrationPath,
|
||||||
|
inputProfileURL: inputURL,
|
||||||
|
outputProfileURL: tmpURL,
|
||||||
|
unapply: false
|
||||||
|
)
|
||||||
|
let outputArgs = try ApplycalArgs.build(config: outputConfig)
|
||||||
|
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: outputArgs,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
guard result.exitCode == 0, !Task.isCancelled else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
if Task.isCancelled {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "applycal",
|
||||||
|
code: result.exitCode,
|
||||||
|
logs: [result.stderr.isEmpty
|
||||||
|
? "applycal exited with code \(result.exitCode)"
|
||||||
|
: result.stderr]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: ["applycal did not create temp profile"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: ["calibrated profile is too small (\(size) bytes)"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if fm.fileExists(atPath: inputURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: inputURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "applycal",
|
||||||
|
code: -1,
|
||||||
|
logs: [error.localizedDescription]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iccgamut (post-colprof gamut mesh)
|
||||||
|
|
||||||
|
/// Extracts a `.gam` mesh from the finished profile.
|
||||||
|
public func runIccgamut(
|
||||||
|
config: IccgamutConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let profileURL = config.profileURL
|
||||||
|
let cwd = profileURL.deletingLastPathComponent()
|
||||||
|
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||||
|
let args = try IccgamutArgs.build(config: config)
|
||||||
|
let processId = ProcessID.iccgamut(stem: stem)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
|
name: "iccgamut",
|
||||||
|
id: processId,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
|
)
|
||||||
|
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||||
|
return try requireArtefact(gamURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - profcheck (Stage 5 verification)
|
||||||
|
|
||||||
|
/// Verifies a profile against the canonical `.ti3`.
|
||||||
|
public func runProfcheck(
|
||||||
|
config: ProfcheckConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> ProfcheckReport {
|
||||||
|
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
|
||||||
|
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
|
||||||
|
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||||
|
|
||||||
|
let args = try ProfcheckArgs.build(config: config)
|
||||||
|
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||||
|
let run = try await runStreamingTool(
|
||||||
|
name: "profcheck",
|
||||||
|
id: processId,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
|
)
|
||||||
|
|
||||||
|
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let report = ProfcheckParser.parse(output)
|
||||||
|
guard report.isValid else {
|
||||||
|
throw ArgyllRunnerError.profcheckUnparseable
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - chartread (Stage 3 interactive)
|
||||||
|
|
||||||
|
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
|
||||||
|
/// are all handled here. Use `sendChartreadInput` to drive the child and
|
||||||
|
/// `cancelChartread` to terminate it.
|
||||||
|
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
|
||||||
|
let cleanBasename: String
|
||||||
|
let cwd: URL
|
||||||
|
do {
|
||||||
|
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: [String]
|
||||||
|
do {
|
||||||
|
args = try ChartreadArgs.build(config: config)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let binaryURL = binaryResolver.resolve("chartread")
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// Register the XY parking hook before spawning.
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", 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):
|
||||||
|
let previous = state
|
||||||
|
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
|
||||||
|
state = classified.state
|
||||||
|
|
||||||
|
if classified.isRemoveSheetNotice {
|
||||||
|
continuation.yield(.removeSheetNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldPrompt =
|
||||||
|
classified.sheetNumber != nil
|
||||||
|
|| classified.alignmentPatch != nil
|
||||||
|
|| classified.requestedWarningKey != nil
|
||||||
|
|| classified.state != previous
|
||||||
|
|| classified.isTableContinuation
|
||||||
|
|
||||||
|
if shouldPrompt {
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow(_, let payload):
|
||||||
|
do {
|
||||||
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
|
||||||
|
state = row.isFinalRow ? .allStripsRead : state
|
||||||
|
continuation.yield(.row(row))
|
||||||
|
} catch {
|
||||||
|
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
if let code = exitCode, code == 0 {
|
||||||
|
if FileManager.default.fileExists(atPath: canonical.path) {
|
||||||
|
continuation.yield(.completed(canonical))
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "chartread",
|
||||||
|
code: exitCode ?? -1,
|
||||||
|
logs: ["chartread exited with code \(exitCode ?? -1)"]
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an exact input sequence to the running `chartread` child.
|
||||||
|
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
try await processManager.sendStdin(id: processId, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `chartread` child.
|
||||||
|
///
|
||||||
|
/// The actual XY parking is handled by the pre-kill hook registered in
|
||||||
|
/// `runChartread`.
|
||||||
|
public func cancelChartread(basename: String, isXY: Bool = false) {
|
||||||
|
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
|
||||||
|
guard let cleanBasename else { return }
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/// Generates a calibration wedge `.ti1`.
|
||||||
|
public func runCalibrationTargen(
|
||||||
|
config: CalibrationTargenConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let args = try CalibrationTargenArgs.build(config: config)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(
|
||||||
|
CalibrationIdentity.prefix(config.basename)
|
||||||
|
)
|
||||||
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
_ = try await runStreamingTool(
|
||||||
|
name: "targen",
|
||||||
|
id: processId,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
onLogBatch: onLogBatch
|
||||||
|
)
|
||||||
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
|
return try requireArtefact(ti1URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
||||||
|
///
|
||||||
|
/// `printcal` is captured (not streamed) and is exempt from the `-u`
|
||||||
|
/// JSON policy.
|
||||||
|
public func runPrintcal(
|
||||||
|
config: PrintcalConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let args = try PrintcalArgs.build(config: config)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let binaryURL = binaryResolver.resolve("printcal")
|
||||||
|
let calBasename = CalibrationIdentity.prefix(config.ti3Basename)
|
||||||
|
let processId = ProcessID.printcal(calBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
if let onLogBatch = onLogBatch, !result.stdout.isEmpty {
|
||||||
|
onLogBatch(result.stdout.components(separatedBy: .newlines))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard result.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.toolFailed(
|
||||||
|
tool: "printcal",
|
||||||
|
code: result.exitCode,
|
||||||
|
logs: [result.stderr.isEmpty
|
||||||
|
? "printcal exited with code \(result.exitCode)"
|
||||||
|
: result.stderr]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let calURL = config.outputURL
|
||||||
|
guard FileManager.default.fileExists(atPath: calURL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(calURL.path)
|
||||||
|
}
|
||||||
|
return calURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events emitted by a running `chartread` session.
|
||||||
|
public enum ChartreadEvent: Sendable {
|
||||||
|
/// Classified prompt / state update.
|
||||||
|
case prompt(ChartreadClassifyResult)
|
||||||
|
/// A decoded `ROW_COLORS_JSON` row.
|
||||||
|
case row(ChartreadRow)
|
||||||
|
/// A batched log chunk (stdout + stderr lines).
|
||||||
|
case log([String])
|
||||||
|
/// Informational "remove last sheet" notice.
|
||||||
|
case removeSheetNotice
|
||||||
|
/// Process exited with the given code.
|
||||||
|
case exit(Int32)
|
||||||
|
/// Successful completion with the canonical `.ti3` URL.
|
||||||
|
case completed(URL)
|
||||||
|
/// Failure (non-zero exit, missing artefact, spawn/parse error).
|
||||||
|
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.
|
||||||
|
public enum ChartreadInput: Sendable {
|
||||||
|
case trigger // " \n"
|
||||||
|
case accept // "\n"
|
||||||
|
case done // "d\n"
|
||||||
|
case quit // "q\n"
|
||||||
|
case customKey(String)
|
||||||
|
|
||||||
|
public var bytes: Data {
|
||||||
|
switch self {
|
||||||
|
case .trigger:
|
||||||
|
return Data(" \n".utf8)
|
||||||
|
case .accept:
|
||||||
|
return Data("\n".utf8)
|
||||||
|
case .done:
|
||||||
|
return Data("d\n".utf8)
|
||||||
|
case .quit:
|
||||||
|
return Data("q\n".utf8)
|
||||||
|
case .customKey(let key):
|
||||||
|
return Data("\(key)\n".utf8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable {
|
|||||||
|
|
||||||
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||||
public func referenceGamut(_ name: String) -> URL {
|
public func referenceGamut(_ name: String) -> URL {
|
||||||
bundledRoot
|
let stem = name.hasSuffix(".gam") ? name : "\(name).gam"
|
||||||
|
return bundledRoot
|
||||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||||
.appendingPathComponent(name, isDirectory: false)
|
.appendingPathComponent(stem, isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the resolved path exists and is executable.
|
/// Whether the resolved path exists and is executable.
|
||||||
|
|||||||
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
case .raster:
|
case .raster:
|
||||||
args.append("-r")
|
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
||||||
!label.isEmpty {
|
|
||||||
args.append(contentsOf: ["-d", label])
|
|
||||||
}
|
|
||||||
|
|
||||||
guard (72...600).contains(config.dpi) else {
|
guard (72...600).contains(config.dpi) else {
|
||||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
}
|
}
|
||||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
if !cleanBasename.hasPrefix("CAL_"),
|
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
||||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
||||||
!cal.isEmpty {
|
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
||||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,18 +70,12 @@ public enum TargenArgs {
|
|||||||
if let n = config.neutralSteps, n > 0 {
|
if let n = config.neutralSteps, n > 0 {
|
||||||
args.append(contentsOf: ["-n", "\(n)"])
|
args.append(contentsOf: ["-n", "\(n)"])
|
||||||
}
|
}
|
||||||
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
||||||
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
||||||
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
||||||
args.append(contentsOf: ["-c", c])
|
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
||||||
}
|
}))
|
||||||
if config.ofpsHighQuality == true {
|
|
||||||
args.append("-G")
|
|
||||||
}
|
|
||||||
if let a = config.ofpsAdaptation {
|
|
||||||
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
|
|
||||||
}
|
|
||||||
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||||
args.append(algFlag)
|
args.append(algFlag)
|
||||||
}
|
}
|
||||||
@@ -91,11 +85,9 @@ public enum TargenArgs {
|
|||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
}
|
}
|
||||||
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
||||||
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
if let p = config.devicePower, p > 0 {
|
||||||
}
|
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
||||||
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
|
||||||
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.append(cleanBasename)
|
args.append(cleanBasename)
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors that can occur while parsing CGATS-like data.
|
||||||
|
public enum CGATSParseError: Error, Equatable {
|
||||||
|
case emptyFile
|
||||||
|
case missingBeginDataFormat
|
||||||
|
case missingEndDataFormat
|
||||||
|
case missingBeginData
|
||||||
|
case missingEndData
|
||||||
|
case missingNumberOfFields
|
||||||
|
case missingNumberOfSets
|
||||||
|
case unknownFieldName(String)
|
||||||
|
case malformedRow(line: Int, reason: String)
|
||||||
|
case nonNumericValue(field: String, value: String, line: Int)
|
||||||
|
case outOfBoundsValue(field: String, value: Double, line: Int)
|
||||||
|
case implausibleValue(field: String, value: Double, line: Int)
|
||||||
|
case incorrectArity(line: Int, expected: Int, got: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a CGATS dataset, keyed by canonical field name.
|
||||||
|
public struct CGATSSample: Sendable, Equatable {
|
||||||
|
public var id: String
|
||||||
|
public var loc: String?
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed CGATS / CTI3 / CSV dataset.
|
||||||
|
public struct CGATSDataset: Sendable, Equatable {
|
||||||
|
public var format: CGATSFormat
|
||||||
|
public var keywords: [String: String]
|
||||||
|
public var fieldNames: [String]
|
||||||
|
public var samples: [CGATSSample]
|
||||||
|
public var colorRep: String?
|
||||||
|
public var deviceClass: String?
|
||||||
|
public var targetInstrument: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
format: CGATSFormat,
|
||||||
|
keywords: [String: String] = [:],
|
||||||
|
fieldNames: [String] = [],
|
||||||
|
samples: [CGATSSample] = [],
|
||||||
|
colorRep: String? = nil,
|
||||||
|
deviceClass: String? = nil,
|
||||||
|
targetInstrument: String? = nil
|
||||||
|
) {
|
||||||
|
self.format = format
|
||||||
|
self.keywords = keywords
|
||||||
|
self.fieldNames = fieldNames
|
||||||
|
self.samples = samples
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.deviceClass = deviceClass
|
||||||
|
self.targetInstrument = targetInstrument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CGATSFormat: String, Sendable, Equatable {
|
||||||
|
case cti3 = "CTI3"
|
||||||
|
case cgats17 = "CGATS.17"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
|
||||||
|
public enum CGATSParser {
|
||||||
|
|
||||||
|
/// Parse the contents of a CGATS-like file.
|
||||||
|
public static func parse(
|
||||||
|
_ contents: String,
|
||||||
|
sourceURL: URL? = nil
|
||||||
|
) throws -> CGATSDataset {
|
||||||
|
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
|
||||||
|
|
||||||
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.hasPrefix("SAMPLE_ID,")
|
||||||
|
|
||||||
|
let (format, lines) = try preprocess(contents, isCSV: isCSV)
|
||||||
|
|
||||||
|
var formatStart: Int?
|
||||||
|
var formatEnd: Int?
|
||||||
|
var dataStart: Int?
|
||||||
|
var dataEnd: Int?
|
||||||
|
var keywords = [String: String]()
|
||||||
|
|
||||||
|
for (index, line) in lines.enumerated() {
|
||||||
|
switch Self.normalizedKeyword(line) {
|
||||||
|
case "BEGIN_DATA_FORMAT": formatStart = index
|
||||||
|
case "END_DATA_FORMAT": formatEnd = index
|
||||||
|
case "BEGIN_DATA": dataStart = index
|
||||||
|
case "END_DATA": dataEnd = index
|
||||||
|
default:
|
||||||
|
if let (key, value) = parseKeyword(line) {
|
||||||
|
keywords[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
|
throw CGATSParseError.missingBeginDataFormat
|
||||||
|
}
|
||||||
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
|
throw CGATSParseError.missingBeginData
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
|
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
|
||||||
|
|
||||||
|
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
|
||||||
|
numberOfFields != fieldNames.count {
|
||||||
|
// Warn only; the data format line is the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_FIELDS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
|
||||||
|
numberOfSets != dataEnd - dataStart - 1 {
|
||||||
|
// Warn only; the actual rows are the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_SETS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawSample {
|
||||||
|
var id: String
|
||||||
|
var loc: String?
|
||||||
|
var numbers: [String: Double] = [:]
|
||||||
|
var strings: [String: String] = [:]
|
||||||
|
var lineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSamples = [RawSample]()
|
||||||
|
var groupMax: [String: Double] = [:]
|
||||||
|
|
||||||
|
for offset in 1...(dataEnd - dataStart - 1) {
|
||||||
|
let lineIndex = dataStart + offset
|
||||||
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
|
guard rawRow.count == fieldNames.count else {
|
||||||
|
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
|
for (i, name) in fieldNames.enumerated() {
|
||||||
|
let raw = stripInlineComment(rawRow[i])
|
||||||
|
if isNumericField(name) {
|
||||||
|
let cleaned = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let number = parseNumber(cleaned) {
|
||||||
|
sample.numbers[name] = number
|
||||||
|
if let group = deviceGroup(name) {
|
||||||
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
|
}
|
||||||
|
} else if !cleaned.isEmpty {
|
||||||
|
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sample.strings[name] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
|
||||||
|
sample.loc = sample.strings["SAMPLE_LOC"]
|
||||||
|
rawSamples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples = [CGATSSample]()
|
||||||
|
for raw in rawSamples {
|
||||||
|
var values = raw.strings
|
||||||
|
for (name, number) in raw.numbers {
|
||||||
|
var scaled = number
|
||||||
|
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
|
||||||
|
scaled = number / 2.55
|
||||||
|
}
|
||||||
|
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
|
||||||
|
// Keep lookups by canonical keys, but also preserve original aliases.
|
||||||
|
let rawRow = splitFields(lines[raw.lineIndex])
|
||||||
|
for (i, rawName) in rawFieldNames.enumerated() {
|
||||||
|
let canonical = canonicalFieldName(rawName)
|
||||||
|
if canonical != rawName {
|
||||||
|
sample.values[rawName] = rawRow[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
samples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
|
||||||
|
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
|
||||||
|
|
||||||
|
return CGATSDataset(
|
||||||
|
format: format,
|
||||||
|
keywords: keywords,
|
||||||
|
fieldNames: fieldNames,
|
||||||
|
samples: samples,
|
||||||
|
colorRep: colorRep,
|
||||||
|
deviceClass: deviceClass,
|
||||||
|
targetInstrument: keywords["TARGET_INSTRUMENT"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a URL (throws as `Error` for public callers).
|
||||||
|
public static func parse(url: URL) throws -> CGATSDataset {
|
||||||
|
let contents = try String(contentsOf: url)
|
||||||
|
return try parse(contents, sourceURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func preprocess(
|
||||||
|
_ contents: String,
|
||||||
|
isCSV: Bool
|
||||||
|
) throws -> (CGATSFormat, [String]) {
|
||||||
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
var format: CGATSFormat?
|
||||||
|
for var line in allLines {
|
||||||
|
line = stripComment(line)
|
||||||
|
line = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !line.isEmpty else { continue }
|
||||||
|
|
||||||
|
if format == nil {
|
||||||
|
if line.hasPrefix("CTI3") { format = .cti3 }
|
||||||
|
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
|
||||||
|
else if isCSV { format = .csv }
|
||||||
|
}
|
||||||
|
|
||||||
|
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
|
||||||
|
line == "BEGIN_DATA" || line == "END_DATA" ||
|
||||||
|
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
|
||||||
|
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
|
||||||
|
// These are exact keywords; keep them intact.
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
|
||||||
|
|
||||||
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
if let format, format == .csv,
|
||||||
|
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
|
||||||
|
let header = lines[0]
|
||||||
|
let data = lines.dropFirst()
|
||||||
|
lines = [
|
||||||
|
"CTI3",
|
||||||
|
"BEGIN_DATA_FORMAT",
|
||||||
|
header,
|
||||||
|
"END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA"
|
||||||
|
] + Array(data) + [
|
||||||
|
"END_DATA"
|
||||||
|
]
|
||||||
|
return (.csv, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (format ?? .cti3, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripComment(_ line: String) -> String {
|
||||||
|
if let range = line.range(of: "#") {
|
||||||
|
return String(line[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripInlineComment(_ token: String) -> String {
|
||||||
|
if let range = token.range(of: "#") {
|
||||||
|
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitFields(_ line: String) -> [String] {
|
||||||
|
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
|
||||||
|
if line.contains(",") {
|
||||||
|
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
|
||||||
|
// KEYWORD value or KEYWORD "value"
|
||||||
|
let tokens = splitFields(line)
|
||||||
|
guard let key = tokens.first else { return nil }
|
||||||
|
|
||||||
|
// Data-boundary keywords are not value keywords.
|
||||||
|
let boundaryKeys = Set([
|
||||||
|
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA", "END_DATA"
|
||||||
|
])
|
||||||
|
guard !boundaryKeys.contains(key) else { return nil }
|
||||||
|
|
||||||
|
let rawValue = tokens.dropFirst().joined(separator: " ")
|
||||||
|
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
return (key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedKeyword(_ line: String) -> String {
|
||||||
|
line.uppercased().trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field name normalization
|
||||||
|
|
||||||
|
private static func canonicalFieldName(_ raw: String) -> String {
|
||||||
|
let upper = raw.uppercased()
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "-", with: "_")
|
||||||
|
switch upper {
|
||||||
|
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
|
||||||
|
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
|
||||||
|
case "SAMPLE_NAME": return "SAMPLE_ID"
|
||||||
|
case "LAB_L", "L*", "L_AB": return "LAB_L"
|
||||||
|
case "LAB_A", "A*", "A_AB": return "LAB_A"
|
||||||
|
case "LAB_B", "B*", "B_AB": return "LAB_B"
|
||||||
|
case "XYZ_X", "X": return "XYZ_X"
|
||||||
|
case "XYZ_Y", "Y": return "XYZ_Y"
|
||||||
|
case "XYZ_Z", "Z": return "XYZ_Z"
|
||||||
|
default: return upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isNumericField(_ name: String) -> Bool {
|
||||||
|
let numericNames: Set = [
|
||||||
|
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
|
||||||
|
]
|
||||||
|
return !numericNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
|
||||||
|
var number = value
|
||||||
|
|
||||||
|
// Plausibility checks for Lab and XYZ.
|
||||||
|
if field == "LAB_L" { number = max(0, min(160, number)) }
|
||||||
|
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
|
||||||
|
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
|
||||||
|
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceGroup(_ name: String) -> String? {
|
||||||
|
if name.hasPrefix("RGB_") { return "RGB" }
|
||||||
|
if name.hasPrefix("CMYK_") { return "CMYK" }
|
||||||
|
if name.hasPrefix("DEVICE_") { return "DEVICE" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferColorRep(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferDeviceClass(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
|
||||||
|
return "OUTPUT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable summary of an imported CGATS dataset.
|
||||||
|
public struct CGATSSummary: Sendable, Equatable {
|
||||||
|
public let patchCount: Int
|
||||||
|
public let colorSpace: String?
|
||||||
|
public let deviceClass: String?
|
||||||
|
public let hasSpectral: Bool
|
||||||
|
public let previewRows: [String]
|
||||||
|
|
||||||
|
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
|
||||||
|
self.patchCount = dataset.samples.count
|
||||||
|
self.colorSpace = dataset.colorRep
|
||||||
|
self.deviceClass = dataset.deviceClass
|
||||||
|
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
|
||||||
|
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
|
||||||
|
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from writing a canonical `.ti3` dataset.
|
||||||
|
public enum CGATSWriterError: Error, Equatable {
|
||||||
|
case noSamples
|
||||||
|
case missingRequiredField(String)
|
||||||
|
case invalidValue(field: String, value: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
|
||||||
|
public enum CGATSWriter {
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset) throws -> String {
|
||||||
|
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
|
||||||
|
throw CGATSWriterError.noSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.append(dataset.format.rawValue)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
|
||||||
|
if let colorRep = dataset.colorRep {
|
||||||
|
lines.append("COLOR_REP \"\(colorRep)\"")
|
||||||
|
}
|
||||||
|
if let deviceClass = dataset.deviceClass {
|
||||||
|
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
|
||||||
|
}
|
||||||
|
if let instrument = dataset.targetInstrument {
|
||||||
|
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
|
||||||
|
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA_FORMAT")
|
||||||
|
lines.append(dataset.fieldNames.joined(separator: "\t"))
|
||||||
|
lines.append("END_DATA_FORMAT")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA")
|
||||||
|
for sample in dataset.samples {
|
||||||
|
let row = try dataset.fieldNames.map { field in
|
||||||
|
guard let raw = sample.values[field], !raw.isEmpty else {
|
||||||
|
throw CGATSWriterError.missingRequiredField(field)
|
||||||
|
}
|
||||||
|
// Normalize numeric fields to a compact decimal.
|
||||||
|
if isNumeric(field) {
|
||||||
|
return normalizedNumber(raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
lines.append(row.joined(separator: "\t"))
|
||||||
|
}
|
||||||
|
lines.append("END_DATA")
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
|
||||||
|
let text = try write(dataset)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func isNumeric(_ field: String) -> Bool {
|
||||||
|
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
|
||||||
|
return !nonNumeric.contains(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedNumber(_ raw: String) -> String {
|
||||||
|
guard let number = Double(raw) else { return raw }
|
||||||
|
if number == floor(number) {
|
||||||
|
return String(format: "%.0f", number)
|
||||||
|
}
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,43 @@ public enum ArtefactFiles {
|
|||||||
try Data(contentsOf: url).base64EncodedString()
|
try Data(contentsOf: url).base64EncodedString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `get_app_info` — version + build for the About dialog.
|
/// `get_app_info` — version, build, and build date for the About dialog.
|
||||||
public static func appInfo(
|
public static func appInfo(
|
||||||
bundle: Bundle = .main
|
bundle: Bundle = .main
|
||||||
) -> (version: String, build: String) {
|
) -> (version: String, build: String, buildDate: String) {
|
||||||
let info = bundle.infoDictionary ?? [:]
|
let info = bundle.infoDictionary ?? [:]
|
||||||
return (
|
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
let build = info["CFBundleVersion"] as? String ?? "0"
|
||||||
info["CFBundleVersion"] as? String ?? "0"
|
var tag = ""
|
||||||
)
|
if let url = bundle.url(forResource: "ICCeryReleaseTag", withExtension: nil),
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8) {
|
||||||
|
tag = raw
|
||||||
|
}
|
||||||
|
let version = displayVersion(shortVersion: short, releaseTag: tag)
|
||||||
|
|
||||||
|
let url = bundle.executableURL ?? bundle.bundleURL
|
||||||
|
let buildDate: String
|
||||||
|
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
|
||||||
|
let date = values.contentModificationDate {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
buildDate = formatter.string(from: date)
|
||||||
|
} else {
|
||||||
|
buildDate = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (version, build, buildDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// About "Version:" string from the release tag + marketing version
|
||||||
|
/// (#189): `v2.0.0-pre2 (2.0.0)`; dedupes to `v2.0.0` on an exact
|
||||||
|
/// release tag; falls back to the short version when untagged.
|
||||||
|
static func displayVersion(shortVersion: String, releaseTag: String) -> String {
|
||||||
|
let tag = releaseTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if tag.isEmpty || tag == shortVersion || tag == "v\(shortVersion)" {
|
||||||
|
return tag.isEmpty ? shortVersion : tag
|
||||||
|
}
|
||||||
|
return "\(tag) (\(shortVersion))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,23 @@ public struct StageArtefacts: Sendable, Equatable {
|
|||||||
public var stage4Complete = false
|
public var stage4Complete = false
|
||||||
/// Absolute path of the profile file when present.
|
/// Absolute path of the profile file when present.
|
||||||
public var profilePath: URL?
|
public var profilePath: URL?
|
||||||
|
/// Absolute path of the `.gam` gamut mesh when present (issue #28).
|
||||||
|
public var gamPath: URL?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
stage1Complete: Bool = false,
|
stage1Complete: Bool = false,
|
||||||
stage2Complete: Bool = false,
|
stage2Complete: Bool = false,
|
||||||
stage3Complete: Bool = false,
|
stage3Complete: Bool = false,
|
||||||
stage4Complete: Bool = false,
|
stage4Complete: Bool = false,
|
||||||
profilePath: URL? = nil
|
profilePath: URL? = nil,
|
||||||
|
gamPath: URL? = nil
|
||||||
) {
|
) {
|
||||||
self.stage1Complete = stage1Complete
|
self.stage1Complete = stage1Complete
|
||||||
self.stage2Complete = stage2Complete
|
self.stage2Complete = stage2Complete
|
||||||
self.stage3Complete = stage3Complete
|
self.stage3Complete = stage3Complete
|
||||||
self.stage4Complete = stage4Complete
|
self.stage4Complete = stage4Complete
|
||||||
self.profilePath = profilePath
|
self.profilePath = profilePath
|
||||||
|
self.gamPath = gamPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +49,10 @@ public enum ArtefactProbe {
|
|||||||
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||||
out.stage4Complete = true
|
out.stage4Complete = true
|
||||||
out.profilePath = profile
|
out.profilePath = profile
|
||||||
|
let gam = artefact(basename, "gam", cwd)
|
||||||
|
if exists(gam, fm: fileManager) {
|
||||||
|
out.gamPath = gam
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,21 @@ public enum ArtefactProbe {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||||
|
/// requested path is missing (#69 / issue #83). Any other extension
|
||||||
|
/// (`.mpp`, `.txt`, …) is returned unchanged — never rewritten.
|
||||||
|
public static func resolveProfile(
|
||||||
|
_ url: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL {
|
||||||
|
if fileManager.fileExists(atPath: url.path) { return url }
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else { return url }
|
||||||
|
let alt = url.deletingPathExtension()
|
||||||
|
.appendingPathExtension(ext == "icc" ? "icm" : "icc")
|
||||||
|
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||||
|
}
|
||||||
|
|
||||||
/// Default extension for a *new* profile on macOS (#69).
|
/// Default extension for a *new* profile on macOS (#69).
|
||||||
public static let defaultProfileExtension = "icc"
|
public static let defaultProfileExtension = "icc"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Policy when a JSON file exists but cannot be decoded.
|
||||||
|
public enum JSONCorruptPolicy: Sendable {
|
||||||
|
/// Return `defaultValue` and leave the file untouched.
|
||||||
|
case replaceWithDefault
|
||||||
|
/// Throw the decode error. Callers must not overwrite the file.
|
||||||
|
case throwCorrupt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared pretty-printed JSON file façade used by settings, wizard state,
|
||||||
|
/// and verification history.
|
||||||
|
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
||||||
|
public let fileURL: URL
|
||||||
|
public let corrupt: JSONCorruptPolicy
|
||||||
|
private let defaultValue: @Sendable () -> T
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL,
|
||||||
|
corrupt: JSONCorruptPolicy,
|
||||||
|
defaultValue: @escaping @Sendable () -> T,
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
self.corrupt = corrupt
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = dateDecoding
|
||||||
|
self.decoder = decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes `value` with the shared pretty / sorted-keys encoder.
|
||||||
|
public func encodePretty(_ value: T) throws -> Data {
|
||||||
|
try encoder.encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() throws -> T {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: fileURL.path) else {
|
||||||
|
return defaultValue()
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: fileURL)
|
||||||
|
} catch {
|
||||||
|
switch corrupt {
|
||||||
|
case .replaceWithDefault:
|
||||||
|
return defaultValue()
|
||||||
|
case .throwCorrupt:
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
switch corrupt {
|
||||||
|
case .replaceWithDefault:
|
||||||
|
return defaultValue()
|
||||||
|
case .throwCorrupt:
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ value: T) throws {
|
||||||
|
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension JSONEncoder {
|
||||||
|
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||||
|
/// and preset export.
|
||||||
|
static func icceryPretty(
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||||
|
) -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
encoder.dateEncodingStrategy = dateEncoding
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
import Foundation
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// A single vertex of an Argyll `.gam` surface mesh.
|
||||||
|
///
|
||||||
|
/// Coordinates follow the v0.8.5 SceneKit convention: `x = a*`, `y = L*`,
|
||||||
|
/// `z = b*` so that the a* (green-red) axis is horizontal, L* (lightness)
|
||||||
|
/// is vertical, and b* (blue-yellow) is depth.
|
||||||
|
public struct GamutVertex: Sendable, Equatable {
|
||||||
|
public let lab: LabColor
|
||||||
|
public let rgb: DisplayRGB
|
||||||
|
public let position: SIMD3<Float>
|
||||||
|
|
||||||
|
public init(lab: LabColor, rgb: DisplayRGB) {
|
||||||
|
self.lab = lab
|
||||||
|
self.rgb = rgb
|
||||||
|
self.position = SIMD3<Float>(Float(lab.a), Float(lab.l), Float(lab.b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A face from an Argyll `.gam` file.
|
||||||
|
///
|
||||||
|
/// Indices are 0-based and index into `GamutMesh.vertices` in the order the
|
||||||
|
/// vertices were pushed by the parser (the `VERTEX_NO` column is discarded).
|
||||||
|
public struct GamutTriangle: Sendable, Equatable {
|
||||||
|
public let a: UInt32
|
||||||
|
public let b: UInt32
|
||||||
|
public let c: UInt32
|
||||||
|
|
||||||
|
public init(a: UInt32, b: UInt32, c: UInt32) {
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
self.c = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed gamut surface mesh.
|
||||||
|
public struct GamutMesh: Sendable, Equatable {
|
||||||
|
public let vertices: [GamutVertex]
|
||||||
|
public let faces: [GamutTriangle]
|
||||||
|
|
||||||
|
public init(vertices: [GamutVertex], faces: [GamutTriangle]) {
|
||||||
|
self.vertices = vertices
|
||||||
|
self.faces = faces
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A printable summary for diagnostics.
|
||||||
|
public var summary: String {
|
||||||
|
"GamutMesh(vertices: \(vertices.count), faces: \(faces.count))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown by ``GamutMeshParser``.
|
||||||
|
public enum GamutMeshParseError: LocalizedError, Equatable, Sendable {
|
||||||
|
case missingFile
|
||||||
|
case readFailed(underlying: String)
|
||||||
|
case emptyFile
|
||||||
|
case noDataBlock
|
||||||
|
case malformedVertexLine(line: Int, content: String)
|
||||||
|
case malformedFaceLine(line: Int, content: String)
|
||||||
|
case outOfBoundsVertexIndex(UInt32, max: UInt32)
|
||||||
|
case invalidLabPlausibility(line: Int, content: String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingFile:
|
||||||
|
return "Gamut file not found."
|
||||||
|
case .readFailed(let reason):
|
||||||
|
return "Could not read gamut file: \(reason)"
|
||||||
|
case .emptyFile:
|
||||||
|
return "Gamut file is empty."
|
||||||
|
case .noDataBlock:
|
||||||
|
return "Gamut file contains no BEGIN_DATA blocks."
|
||||||
|
case .malformedVertexLine(let line, let content):
|
||||||
|
return "Malformed vertex on line \(line): \(content)"
|
||||||
|
case .malformedFaceLine(let line, let content):
|
||||||
|
return "Malformed face on line \(line): \(content)"
|
||||||
|
case .outOfBoundsVertexIndex(let index, let max):
|
||||||
|
return "Face references vertex \(index) but only \(max + 1) vertices exist."
|
||||||
|
case .invalidLabPlausibility(let line, let content):
|
||||||
|
return "Lab value outside plausible range on line \(line): \(content)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses Argyll `.gam` ASCII files into ``GamutMesh``.
|
||||||
|
///
|
||||||
|
/// The parser recognises two `BEGIN_DATA` … `END_DATA` blocks:
|
||||||
|
///
|
||||||
|
/// 1. Vertices: `VERTEX_NO LAB_L LAB_A LAB_B`
|
||||||
|
/// 2. Faces: `VERTEX_0 VERTEX_1 VERTEX_2` (0-based indices)
|
||||||
|
///
|
||||||
|
/// Lines beginning with `#` and blank lines are ignored. `BEGIN_DATA` and
|
||||||
|
/// `END_DATA` are matched case-insensitively. The `VERTEX_NO` column is
|
||||||
|
/// discarded; vertices are indexed in push order, matching Argyll's output.
|
||||||
|
public enum GamutMeshParser {
|
||||||
|
|
||||||
|
/// Parse the file at `url`.
|
||||||
|
public static func parse(url: URL) throws -> GamutMesh {
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
throw GamutMeshParseError.missingFile
|
||||||
|
}
|
||||||
|
guard let data = FileManager.default.contents(atPath: url.path) else {
|
||||||
|
throw GamutMeshParseError.readFailed(underlying: "contents(atPath:) returned nil")
|
||||||
|
}
|
||||||
|
guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii),
|
||||||
|
!text.isEmpty else {
|
||||||
|
throw GamutMeshParseError.emptyFile
|
||||||
|
}
|
||||||
|
return try parse(text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse raw `.gam` text.
|
||||||
|
public static func parse(text: String) throws -> GamutMesh {
|
||||||
|
var vertices: [GamutVertex] = []
|
||||||
|
var faces: [GamutTriangle] = []
|
||||||
|
|
||||||
|
var dataBlock = 0
|
||||||
|
var inData = false
|
||||||
|
var lineNumber = 0
|
||||||
|
var warnings: [String] = []
|
||||||
|
|
||||||
|
for rawLine in text.components(separatedBy: .newlines) {
|
||||||
|
lineNumber += 1
|
||||||
|
|
||||||
|
// Strip inline `#` comments before any other processing.
|
||||||
|
let uncommented = rawLine.split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
|
||||||
|
let trimmed = uncommented.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
|
||||||
|
let upper = trimmed.uppercased()
|
||||||
|
|
||||||
|
if upper == "BEGIN_DATA" {
|
||||||
|
dataBlock += 1
|
||||||
|
inData = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if upper == "END_DATA" {
|
||||||
|
inData = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inData { continue }
|
||||||
|
|
||||||
|
let parts = trimmed.components(separatedBy: .whitespaces)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.compactMap(Double.init)
|
||||||
|
|
||||||
|
guard !parts.isEmpty else { continue }
|
||||||
|
|
||||||
|
if dataBlock == 1 {
|
||||||
|
// Vertex format: index L a b
|
||||||
|
guard parts.count >= 4 else {
|
||||||
|
warnings.append("vertex arity \(parts.count) on line \(lineNumber)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let l = parts[1]
|
||||||
|
let a = parts[2]
|
||||||
|
let b = parts[3]
|
||||||
|
|
||||||
|
if l < 0 || l > 100 || abs(a) > 128 || abs(b) > 128 {
|
||||||
|
warnings.append("Lab plausibility warning on line \(lineNumber): L=\(l) a=\(a) b=\(b)")
|
||||||
|
// We still keep the vertex; Argyll can exceed ±128.
|
||||||
|
}
|
||||||
|
|
||||||
|
let lab = LabColor(l: l, a: a, b: b)
|
||||||
|
let rgb = LabColorMath.labToSRGB(lab)
|
||||||
|
vertices.append(GamutVertex(lab: lab, rgb: rgb))
|
||||||
|
} else {
|
||||||
|
// Face format: v0 v1 v2 (can extend for future n-gons, take first 3)
|
||||||
|
guard parts.count >= 3 else {
|
||||||
|
warnings.append("face arity \(parts.count) on line \(lineNumber)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let idx = parts.prefix(3).compactMap { UInt32(exactly: $0) }
|
||||||
|
guard idx.count == 3 else {
|
||||||
|
warnings.append("non-integer face indices on line \(lineNumber)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
faces.append(GamutTriangle(a: idx[0], b: idx[1], c: idx[2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim out-of-bounds face indices instead of throwing, so a slightly
|
||||||
|
// malformed file still renders. This matches the Web viewer's
|
||||||
|
// forgiving posture while surfacing the obvious cases.
|
||||||
|
let validFaces = faces.filter { face in
|
||||||
|
let max = UInt32(vertices.count)
|
||||||
|
guard face.a < max, face.b < max, face.c < max else {
|
||||||
|
warnings.append("dropping face \(face) referencing missing vertex")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataBlock == 0 {
|
||||||
|
throw GamutMeshParseError.noDataBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
return GamutMesh(vertices: vertices, faces: validFaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,78 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `average` argv construction.
|
||||||
|
public enum AverageArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPassCount(Int)
|
||||||
|
case outputCollidesWithInput
|
||||||
|
case pathOutsideCwd(URL)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid basename for average: \(name)"
|
||||||
|
case .invalidPassCount(let count):
|
||||||
|
return "Average requires at least 2 pass files, got \(count)"
|
||||||
|
case .outputCollidesWithInput:
|
||||||
|
return "Average output filename collides with one of the inputs"
|
||||||
|
case .pathOutsideCwd(let url):
|
||||||
|
return "Pass or output file is outside the working directory: \(url.path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for an `average` run.
|
||||||
|
public struct AverageConfig: Sendable, Equatable {
|
||||||
|
public let workingDirectory: URL
|
||||||
|
public let basename: String
|
||||||
|
public let passFiles: [URL]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workingDirectory: URL,
|
||||||
|
basename: String,
|
||||||
|
passFiles: [URL]
|
||||||
|
) {
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.basename = basename
|
||||||
|
self.passFiles = passFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `average` tool.
|
||||||
|
public enum AverageArgs {
|
||||||
|
|
||||||
|
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
|
||||||
|
public static func build(config: AverageConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
guard config.passFiles.count >= 2 else {
|
||||||
|
throw AverageArgError.invalidPassCount(config.passFiles.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = config.workingDirectory
|
||||||
|
.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
|
||||||
|
var inputNames: [String] = []
|
||||||
|
for url in config.passFiles {
|
||||||
|
try validate(url, isIn: config.workingDirectory)
|
||||||
|
inputNames.append(url.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
try validate(output, isIn: config.workingDirectory)
|
||||||
|
let outputName = output.lastPathComponent
|
||||||
|
|
||||||
|
guard !inputNames.contains(outputName) else {
|
||||||
|
throw AverageArgError.outputCollidesWithInput
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["-v"] + inputNames + [outputName]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validate(_ url: URL, isIn cwd: URL) throws {
|
||||||
|
let cwdPath = cwd.standardizedFileURL.path
|
||||||
|
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
|
||||||
|
guard urlPath == cwdPath else {
|
||||||
|
throw AverageArgError.pathOutsideCwd(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `chartread` tool.
|
||||||
|
public enum ChartreadArgs {
|
||||||
|
|
||||||
|
/// Builds `chartread` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// - Always `-v -u`.
|
||||||
|
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
|
||||||
|
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
|
||||||
|
/// - Basename is the last positional argument and is sanitized.
|
||||||
|
public static func build(config: ChartreadConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = ["-v", "-u"]
|
||||||
|
|
||||||
|
if let port = config.selectedPort, port > 1 {
|
||||||
|
args.append(contentsOf: ["-c", "\(port)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enableLEDs {
|
||||||
|
args.append(contentsOf: ["-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Discrete states for the `chartread` interaction.
|
||||||
|
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
|
||||||
|
case idle
|
||||||
|
case calibrating
|
||||||
|
case awaitingStrip
|
||||||
|
case reading
|
||||||
|
case allStripsRead
|
||||||
|
case warning
|
||||||
|
case promptContinue
|
||||||
|
case tablePlaceSheet
|
||||||
|
case tableAlign
|
||||||
|
case error
|
||||||
|
case finished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra metadata produced by classifying a single `chartread` stdout line.
|
||||||
|
public struct ChartreadClassifyResult: Sendable, Equatable {
|
||||||
|
public let state: ChartreadState
|
||||||
|
/// Whether this line is an informational "remove last sheet" notice.
|
||||||
|
public let isRemoveSheetNotice: Bool
|
||||||
|
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
|
||||||
|
public let sheetNumber: Int?
|
||||||
|
public let sheetTotal: Int?
|
||||||
|
/// Fiducial patch name from XY "locate patch X with the sight".
|
||||||
|
public let alignmentPatch: String?
|
||||||
|
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
|
||||||
|
public let requestedWarningKey: String?
|
||||||
|
/// Whether the line is a continuation of a multi-line XY prompt.
|
||||||
|
public let isTableContinuation: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: ChartreadState,
|
||||||
|
isRemoveSheetNotice: Bool = false,
|
||||||
|
sheetNumber: Int? = nil,
|
||||||
|
sheetTotal: Int? = nil,
|
||||||
|
alignmentPatch: String? = nil,
|
||||||
|
requestedWarningKey: String? = nil,
|
||||||
|
isTableContinuation: Bool = false
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.isRemoveSheetNotice = isRemoveSheetNotice
|
||||||
|
self.sheetNumber = sheetNumber
|
||||||
|
self.sheetTotal = sheetTotal
|
||||||
|
self.alignmentPatch = alignmentPatch
|
||||||
|
self.requestedWarningKey = requestedWarningKey
|
||||||
|
self.isTableContinuation = isTableContinuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure line classifier for `chartread` stdout.
|
||||||
|
///
|
||||||
|
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
|
||||||
|
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
|
||||||
|
public enum ChartreadClassifier {
|
||||||
|
|
||||||
|
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
for matcher in matchers(previousState) {
|
||||||
|
if let result = matcher(text, previousState) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
|
||||||
|
[
|
||||||
|
removeSheetNotice,
|
||||||
|
sheetReadOk,
|
||||||
|
locatePatch,
|
||||||
|
placeSheet,
|
||||||
|
continuation(previous),
|
||||||
|
done,
|
||||||
|
warning,
|
||||||
|
calibration,
|
||||||
|
awaitingStrip,
|
||||||
|
reading,
|
||||||
|
error
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. "Please remove last sheet from table" — info only.
|
||||||
|
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. "Sheet N of M read OK".
|
||||||
|
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: previous,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. "locate patch X with the sight".
|
||||||
|
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
|
||||||
|
!match.0.isEmpty else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tableAlign,
|
||||||
|
alignmentPatch: match.0.uppercased()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. "place sheet N of M" or "remove previous sheet".
|
||||||
|
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tablePlaceSheet,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if text.contains("remove previous sheet") || text.contains("place sheet") {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. "hit return to continue" — sticky if already in a table state.
|
||||||
|
private static func continuation(_ previous: ChartreadState) -> Matcher {
|
||||||
|
return { text, _ in
|
||||||
|
guard text.contains("hit return to continue")
|
||||||
|
|| text.contains("hit any key to continue")
|
||||||
|
|| text.contains("hit space to continue")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if case .tablePlaceSheet = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
if case .tableAlign = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Done / all read.
|
||||||
|
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let phrases = [
|
||||||
|
"'d' if/when done", "d to finish/save", "all strips/patches read",
|
||||||
|
"all strips read", "all patches read", "done reading",
|
||||||
|
"'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
|
||||||
|
]
|
||||||
|
if phrases.contains(where: { text.contains($0) }) {
|
||||||
|
return ChartreadClassifyResult(state: .allStripsRead)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Warnings / prompts needing a key.
|
||||||
|
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text
|
||||||
|
let warningSignals = [
|
||||||
|
"(warning)", "use it anyway", "seem to have read strip",
|
||||||
|
"unexpected response", "try again", "do you want to",
|
||||||
|
"abort ? - are you sure", "are you sure"
|
||||||
|
]
|
||||||
|
|
||||||
|
let isWarningPrompt =
|
||||||
|
warningSignals.contains(where: { lower.contains($0) })
|
||||||
|
|| lower.contains("(y/n)")
|
||||||
|
|| lower.contains("'y' or 'n'")
|
||||||
|
|| lower.contains("?")
|
||||||
|
|
||||||
|
guard isWarningPrompt else { return nil }
|
||||||
|
|
||||||
|
var key: String?
|
||||||
|
if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
|
||||||
|
// Default to asking the user; no automatic key.
|
||||||
|
key = nil
|
||||||
|
} else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
|
||||||
|
key = "y"
|
||||||
|
} else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
|
||||||
|
key = "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Calibration / place reference / white / standard tile.
|
||||||
|
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
|
||||||
|
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
|
||||||
|
let hasLocate = lowercased.contains("locate patch")
|
||||||
|
|
||||||
|
guard placeTokens.contains(where: { lowercased.contains($0) }),
|
||||||
|
!hasPlaceSheet,
|
||||||
|
!hasLocate
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if lowercased.contains("calibrat")
|
||||||
|
|| lowercased.contains("white reference")
|
||||||
|
|| lowercased.contains("white tile")
|
||||||
|
|| lowercased.contains("standard tile")
|
||||||
|
|| lowercased.contains("reference")
|
||||||
|
|| lowercased.contains("tile")
|
||||||
|
|| lowercased.contains("hit any key to continue")
|
||||||
|
|| lowercased.contains("hit space to continue") {
|
||||||
|
return ChartreadClassifyResult(state: .calibrating)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Awaiting strip.
|
||||||
|
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
|
||||||
|
// These are explicit, multi-word prompts; we deliberately do NOT
|
||||||
|
// match bare "read strip" so that error lines like
|
||||||
|
// "failed to read strip" or "error reading strip" fall through to
|
||||||
|
// the error matcher.
|
||||||
|
let phrases = [
|
||||||
|
"ready to read",
|
||||||
|
"hit any key to read",
|
||||||
|
"hit a key to read",
|
||||||
|
"hit space to read",
|
||||||
|
"hit [space] to read",
|
||||||
|
"press any key to read",
|
||||||
|
"press space to read",
|
||||||
|
"trigger instrument",
|
||||||
|
"start reading",
|
||||||
|
"read next strip"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Also permit "hit X to read strip Y" or "ready to read strip Z".
|
||||||
|
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Reading.
|
||||||
|
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .reading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Error.
|
||||||
|
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text.lowercased()
|
||||||
|
|
||||||
|
// Avoid false positives from confirmation prompts and "no error" status.
|
||||||
|
guard !lower.contains("no error") else { return nil }
|
||||||
|
guard !lower.contains("(y/n)")
|
||||||
|
&& !lower.contains("'y' or 'n'")
|
||||||
|
&& !lower.contains("?")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
|
||||||
|
for phrase in phraseMatches {
|
||||||
|
if lower.contains(phrase) {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-word "error" only — bare "failed" alone is not enough.
|
||||||
|
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func firstMatch(pattern: String) -> (String, Int, Int)? {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
|
||||||
|
let r = match.range(at: i)
|
||||||
|
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
|
||||||
|
return String(self[range])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let first = groups.first else { return nil }
|
||||||
|
let ints = groups.compactMap { Int($0) }
|
||||||
|
let a = ints.count > 0 ? ints[0] : 0
|
||||||
|
let b = ints.count > 1 ? ints[1] : 0
|
||||||
|
return (first, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `ChartreadArgs` validation.
|
||||||
|
public enum ChartreadArgError: LocalizedError, Equatable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPort(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid chart basename: \(name)"
|
||||||
|
case .invalidPort(let port):
|
||||||
|
return "Invalid chartread port: \(port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a `chartread` invocation.
|
||||||
|
public struct ChartreadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1).
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false
|
||||||
|
) {
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current config implies an XY-table workflow.
|
||||||
|
/// This is normally supplied by the view model from the selected instrument.
|
||||||
|
public var isXY: Bool = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single patch read by `chartread`.
|
||||||
|
public struct ChartreadPatch: Codable, Sendable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let loc: String
|
||||||
|
public let isPad: Bool
|
||||||
|
public let device: [Double]
|
||||||
|
public let expected: PatchColor?
|
||||||
|
public let measured: PatchColor
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
loc: String,
|
||||||
|
isPad: Bool,
|
||||||
|
device: [Double],
|
||||||
|
expected: PatchColor?,
|
||||||
|
measured: PatchColor
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.isPad = isPad
|
||||||
|
self.device = device
|
||||||
|
self.expected = expected
|
||||||
|
self.measured = measured
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, loc
|
||||||
|
case isPad = "is_pad"
|
||||||
|
case device, expected, measured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour payload carried by `expected` or `measured`.
|
||||||
|
public struct PatchColor: Codable, Sendable, Equatable {
|
||||||
|
public let xyz: CIEXYZ?
|
||||||
|
public let lab: CIELab?
|
||||||
|
public let spectral: SpectralData?
|
||||||
|
|
||||||
|
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
|
||||||
|
self.xyz = xyz
|
||||||
|
self.lab = lab
|
||||||
|
self.spectral = spectral
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case xyz = "XYZ"
|
||||||
|
case lab = "Lab"
|
||||||
|
case spectral = "spectral"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SpectralData: Codable, Sendable, Equatable {
|
||||||
|
public let bands: Int
|
||||||
|
public let startNM: Double
|
||||||
|
public let endNM: Double
|
||||||
|
public let norm: Double
|
||||||
|
public let values: [Double]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case bands
|
||||||
|
case startNM = "start_nm"
|
||||||
|
case endNM = "end_nm"
|
||||||
|
case norm
|
||||||
|
case values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete row emitted by `chartread -u`.
|
||||||
|
public struct ChartreadRow: Codable, Sendable, Equatable {
|
||||||
|
public let event: String
|
||||||
|
public let rowId: String
|
||||||
|
public let rowIndex: Int
|
||||||
|
public let totalRows: Int
|
||||||
|
public let patchCount: Int
|
||||||
|
public let patches: [ChartreadPatch]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case event
|
||||||
|
case rowId = "row_id"
|
||||||
|
case rowIndex = "row_index"
|
||||||
|
case totalRows = "total_rows"
|
||||||
|
case patchCount = "patch_count"
|
||||||
|
case patches
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isFinalRow: Bool {
|
||||||
|
rowIndex + 1 >= totalRows
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of evaluating one measured patch.
|
||||||
|
public struct SwatchEvaluation: Sendable, Equatable {
|
||||||
|
public let intended: DisplayRGB
|
||||||
|
public let measured: DisplayRGB
|
||||||
|
public let deltaE: Double?
|
||||||
|
public let classification: SwatchClassification
|
||||||
|
|
||||||
|
public init(
|
||||||
|
intended: DisplayRGB,
|
||||||
|
measured: DisplayRGB,
|
||||||
|
deltaE: Double?,
|
||||||
|
classification: SwatchClassification
|
||||||
|
) {
|
||||||
|
self.intended = intended
|
||||||
|
self.measured = measured
|
||||||
|
self.deltaE = deltaE
|
||||||
|
self.classification = classification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
|
||||||
|
case good
|
||||||
|
case warning
|
||||||
|
case bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIEDE2000 ΔE₀₀ between two D50 Lab values.
|
||||||
|
public enum ColorDifference {
|
||||||
|
|
||||||
|
/// Compute ΔE₀₀ using the full CIEDE2000 formula.
|
||||||
|
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
|
||||||
|
let kL: Double = 1
|
||||||
|
let kC: Double = 1
|
||||||
|
let kH: Double = 1
|
||||||
|
|
||||||
|
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
|
||||||
|
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let cBar = (c1 + c2) / 2.0
|
||||||
|
let cBar7 = pow(cBar, 7)
|
||||||
|
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
|
||||||
|
|
||||||
|
let a1p = (1 + g) * lab1.a
|
||||||
|
let a2p = (1 + g) * lab2.a
|
||||||
|
|
||||||
|
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
|
||||||
|
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let h1p = atan2ToDegrees(lab1.b, a1p)
|
||||||
|
let h2p = atan2ToDegrees(lab2.b, a2p)
|
||||||
|
|
||||||
|
let deltaLp = lab2.l - lab1.l
|
||||||
|
let deltaCp = c2p - c1p
|
||||||
|
|
||||||
|
var deltaHp: Double = 0
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
deltaHp = 0
|
||||||
|
} else {
|
||||||
|
let diff = h2p - h1p
|
||||||
|
if abs(diff) <= 180 {
|
||||||
|
deltaHp = diff
|
||||||
|
} else if diff > 180 {
|
||||||
|
deltaHp = diff - 360
|
||||||
|
} else {
|
||||||
|
deltaHp = diff + 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
|
||||||
|
|
||||||
|
let lBarp = (lab1.l + lab2.l) / 2.0
|
||||||
|
let cBarp = (c1p + c2p) / 2.0
|
||||||
|
|
||||||
|
var hBarp: Double
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
hBarp = h1p + h2p
|
||||||
|
} else {
|
||||||
|
if abs(h1p - h2p) <= 180 {
|
||||||
|
hBarp = (h1p + h2p) / 2.0
|
||||||
|
} else if h1p + h2p < 360 {
|
||||||
|
hBarp = (h1p + h2p + 360) / 2.0
|
||||||
|
} else {
|
||||||
|
hBarp = (h1p + h2p - 360) / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = 1
|
||||||
|
- 0.17 * cos(deg2rad(hBarp - 30))
|
||||||
|
+ 0.24 * cos(deg2rad(2 * hBarp))
|
||||||
|
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
|
||||||
|
- 0.20 * cos(deg2rad(4 * hBarp - 63))
|
||||||
|
|
||||||
|
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
|
||||||
|
let cBarp7 = pow(cBarp, 7)
|
||||||
|
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
|
||||||
|
|
||||||
|
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
|
||||||
|
let sc = 1 + 0.045 * cBarp
|
||||||
|
let sh = 1 + 0.015 * cBarp * t
|
||||||
|
|
||||||
|
let rt = -sin(deg2rad(2 * dTheta)) * rc
|
||||||
|
|
||||||
|
let lTerm = deltaLp / (kL * sl)
|
||||||
|
let cTerm = deltaCp / (kC * sc)
|
||||||
|
let hTerm = deltaHp2 / (kH * sh)
|
||||||
|
|
||||||
|
return sqrt(
|
||||||
|
lTerm * lTerm
|
||||||
|
+ cTerm * cTerm
|
||||||
|
+ hTerm * hTerm
|
||||||
|
+ rt * cTerm * hTerm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a ΔE value against user thresholds.
|
||||||
|
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
|
||||||
|
if deltaE < goodMax { return .good }
|
||||||
|
if deltaE < warningMax { return .warning }
|
||||||
|
return .bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
|
||||||
|
public static func evaluate(
|
||||||
|
patch: ChartreadPatch,
|
||||||
|
goodMax: Double,
|
||||||
|
warningMax: Double
|
||||||
|
) -> SwatchEvaluation? {
|
||||||
|
guard let measured = resolveLab(patch.measured) else { return nil }
|
||||||
|
|
||||||
|
let measuredRGB = LabColorMath.labToSRGB(measured)
|
||||||
|
|
||||||
|
if let expectedColor = patch.expected,
|
||||||
|
let expectedLab = resolveLab(expectedColor) {
|
||||||
|
let de = deltaE00(expectedLab, measured)
|
||||||
|
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: intendedRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: de,
|
||||||
|
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No reference: still render measured colour, no ΔE.
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: measuredRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: nil,
|
||||||
|
classification: .good
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||||
|
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||||
|
if let lab = color.lab { return lab }
|
||||||
|
guard let xyz = color.xyz else { return nil }
|
||||||
|
return LabColorMath.xyzToLab(xyz)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||||
|
let radians = atan2(y, x)
|
||||||
|
var degrees = radians * 180.0 / .pi
|
||||||
|
if degrees < 0 { degrees += 360 }
|
||||||
|
return degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deg2rad(_ degrees: Double) -> Double {
|
||||||
|
degrees * .pi / 180.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A device discovered by the Argyll `instlist` fork.
|
||||||
|
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public let port: Int
|
||||||
|
public let name: String
|
||||||
|
public let type: String
|
||||||
|
|
||||||
|
public init(port: Int, name: String, type: String) {
|
||||||
|
self.port = port
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: Int { port }
|
||||||
|
|
||||||
|
/// XY tables are identified by name or type matching the fork pattern.
|
||||||
|
public var isXY: Bool {
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
let pattern = #"/spectro\s?scan|i1io/"#
|
||||||
|
return combined.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label shown in the picker.
|
||||||
|
public var displayName: String {
|
||||||
|
let xyTag = isXY ? " · XY Table" : ""
|
||||||
|
return "\(name) [\(type)]\(xyTag)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user’s choice for a chartread session.
|
||||||
|
public enum InstrumentSelection: Sendable, Equatable {
|
||||||
|
/// Auto / first available port — `chartread` omits `-c`.
|
||||||
|
case auto
|
||||||
|
/// A concrete instrument.
|
||||||
|
case device(InstrumentDevice)
|
||||||
|
|
||||||
|
/// The value to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
|
||||||
|
public var chartreadPort: Int? {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return nil
|
||||||
|
case .device(let device):
|
||||||
|
return device.port == 1 ? nil : device.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current selection implies an XY table workflow.
|
||||||
|
public var isXY: Bool {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return false
|
||||||
|
case .device(let device):
|
||||||
|
return device.isXY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `instlist` output parsing.
|
||||||
|
public enum InstrumentParserError: Error, Sendable, Equatable {
|
||||||
|
case malformedJSON
|
||||||
|
case missingDevices
|
||||||
|
case invalidPort
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the Argyll `instlist` stdout document.
|
||||||
|
///
|
||||||
|
/// Fork `instlist` emits pretty-printed JSON with the shape
|
||||||
|
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
|
||||||
|
/// If JSON decoding fails, a constrained regex fallback is used.
|
||||||
|
/// Only lines accepted by the fallback must also match known instrument tokens.
|
||||||
|
public enum InstrumentParser {
|
||||||
|
|
||||||
|
/// Known instrument tokens used by the regex fallback.
|
||||||
|
public static let knownInstrumentPattern =
|
||||||
|
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
|
||||||
|
|
||||||
|
/// Parse the complete `instlist` output.
|
||||||
|
public static func parse(_ output: String) throws -> [InstrumentDevice] {
|
||||||
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
|
||||||
|
if let data = trimmed.data(using: .utf8),
|
||||||
|
let decoded = try? decodeJSON(data) {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
|
||||||
|
options: [.caseInsensitive, .anchorsMatchLines]
|
||||||
|
)
|
||||||
|
var devices: [InstrumentDevice] = []
|
||||||
|
let range = NSRange(trimmed.startIndex..., in: trimmed)
|
||||||
|
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let portString = substring(trimmed, range: match.range(at: 1)),
|
||||||
|
let port = Int(portString), port > 0 else { continue }
|
||||||
|
|
||||||
|
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
guard combined.range(of: knownInstrumentPattern,
|
||||||
|
options: [.regularExpression, .caseInsensitive]) != nil,
|
||||||
|
!name.isEmpty else { continue }
|
||||||
|
|
||||||
|
devices.append(InstrumentDevice(port: port, name: name, type: type))
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
|
||||||
|
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
|
||||||
|
return output.devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func substring(_ source: String, range: NSRange) -> String? {
|
||||||
|
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
|
||||||
|
return String(source[r])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstlistOutput: Decodable {
|
||||||
|
let event: String
|
||||||
|
let devices: [InstrumentDevice]
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||||
|
/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
|
||||||
|
public struct XYZColor: Codable, Sendable, Equatable {
|
||||||
|
public let x: Double
|
||||||
|
public let y: Double
|
||||||
|
public let z: Double
|
||||||
|
|
||||||
|
public init(x: Double, y: Double, z: Double) {
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.z = z
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.x = try container.decode(Double.self)
|
||||||
|
self.y = try container.decode(Double.self)
|
||||||
|
self.z = try container.decode(Double.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(x)
|
||||||
|
try container.encode(y)
|
||||||
|
try container.encode(z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
|
||||||
|
public struct LabColor: Codable, Sendable, Equatable {
|
||||||
|
public let l: Double
|
||||||
|
public let a: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(l: Double, a: Double, b: Double) {
|
||||||
|
self.l = l
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.l = try container.decode(Double.self)
|
||||||
|
self.a = try container.decode(Double.self)
|
||||||
|
self.b = try container.decode(Double.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(l)
|
||||||
|
try container.encode(a)
|
||||||
|
try container.encode(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON aliases used by `chartread` row payloads.
|
||||||
|
public typealias CIEXYZ = XYZColor
|
||||||
|
public typealias CIELab = LabColor
|
||||||
|
|
||||||
|
/// sRGB colour in 0–1 display space.
|
||||||
|
public struct DisplayRGB: Sendable, Equatable {
|
||||||
|
public let r: Double
|
||||||
|
public let g: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(r: Double, g: Double, b: Double) {
|
||||||
|
self.r = r
|
||||||
|
self.g = g
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
public var clamped: DisplayRGB {
|
||||||
|
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour-space conversions used by the swatch grid.
|
||||||
|
///
|
||||||
|
/// All numeric paths are deterministic and avoid platform colour-management APIs.
|
||||||
|
public enum LabColorMath {
|
||||||
|
|
||||||
|
// Reference white for D50 (0–100 scale).
|
||||||
|
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
|
||||||
|
|
||||||
|
// Reference white for D65 (0–100 scale).
|
||||||
|
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
|
||||||
|
|
||||||
|
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
|
||||||
|
static let bradford = [
|
||||||
|
[ 0.8951, 0.2664, -0.1614],
|
||||||
|
[-0.7502, 1.7135, 0.0367],
|
||||||
|
[ 0.0389, -0.0685, 1.0296]
|
||||||
|
]
|
||||||
|
|
||||||
|
static let bradfordInv = [
|
||||||
|
[ 0.9869929, -0.1470543, 0.1599627],
|
||||||
|
[ 0.4323053, 0.5183603, 0.0492912],
|
||||||
|
[-0.0085287, 0.0400428, 0.9684866]
|
||||||
|
]
|
||||||
|
|
||||||
|
// sRGB D65 matrix (XYZ -> linear sRGB, using 0–100 inputs).
|
||||||
|
static let srgbMatrix = [
|
||||||
|
[ 3.2406, -1.5372, -0.4986],
|
||||||
|
[-0.9689, 1.8758, 0.0415],
|
||||||
|
[ 0.0557, -0.2040, 1.0570]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Convert XYZ (0–100) to CIELab D50.
|
||||||
|
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
|
||||||
|
let f: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta * delta * delta {
|
||||||
|
return pow(t, 1.0 / 3.0)
|
||||||
|
} else {
|
||||||
|
return t / (3 * delta * delta) + 4.0 / 29.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = f(xyz.x / d50White.X)
|
||||||
|
let y = f(xyz.y / d50White.Y)
|
||||||
|
let zr = f(xyz.z / d50White.Z)
|
||||||
|
|
||||||
|
return LabColor(
|
||||||
|
l: 116.0 * y - 16.0,
|
||||||
|
a: 500.0 * (x - y),
|
||||||
|
b: 200.0 * (y - zr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert CIELab D50 to XYZ (0–100).
|
||||||
|
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
|
||||||
|
let finv: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta {
|
||||||
|
return t * t * t
|
||||||
|
} else {
|
||||||
|
return 3 * delta * delta * (t - 4.0 / 29.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let yr = (lab.l + 16.0) / 116.0
|
||||||
|
let xr = yr + lab.a / 500.0
|
||||||
|
let zr = yr - lab.b / 200.0
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: finv(xr) * d50White.X,
|
||||||
|
y: finv(yr) * d50White.Y,
|
||||||
|
z: finv(zr) * d50White.Z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
|
||||||
|
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
|
||||||
|
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
|
||||||
|
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
|
||||||
|
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
|
||||||
|
|
||||||
|
let scaled = [
|
||||||
|
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||||
|
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||||
|
source[2] * (dstWhite[2] / srcWhite[2])
|
||||||
|
]
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
|
||||||
|
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
|
||||||
|
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D65 (0–100) to linear sRGB, apply gamma, and clamp.
|
||||||
|
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
|
||||||
|
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
|
||||||
|
// The input is 0–100, so scale by 100 first.
|
||||||
|
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
|
||||||
|
let linear = matrixMultiply(srgbMatrix, scaled)
|
||||||
|
|
||||||
|
func gamma(_ c: Double) -> Double {
|
||||||
|
if c <= 0.0031308 {
|
||||||
|
return 12.92 * c
|
||||||
|
} else {
|
||||||
|
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DisplayRGB(
|
||||||
|
r: gamma(linear[0]),
|
||||||
|
g: gamma(linear[1]),
|
||||||
|
b: gamma(linear[2])
|
||||||
|
).clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete D50 Lab -> display sRGB conversion.
|
||||||
|
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
|
||||||
|
let xyz50 = labToXYZ(lab)
|
||||||
|
let xyz65 = adaptD50ToD65(xyz50)
|
||||||
|
return xyzToSRGB(xyz65)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an Argyll XYZ array (0–100) to Lab and then to sRGB.
|
||||||
|
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
|
||||||
|
guard xyz.count >= 3 else { return nil }
|
||||||
|
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||||
|
var result = [Double](repeating: 0, count: m.count)
|
||||||
|
for i in 0..<m.count {
|
||||||
|
for j in 0..<v.count {
|
||||||
|
result[i] += m[i][j] * v[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from pass-snapshot, promote, and discovery operations.
|
||||||
|
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case canonicalMissing(URL)
|
||||||
|
case snapshotFailed(String)
|
||||||
|
case promoteFailed(String)
|
||||||
|
case noPassFiles
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid measurement basename: \(name)"
|
||||||
|
case .canonicalMissing(let url):
|
||||||
|
return "Canonical .ti3 not found: \(url.path)"
|
||||||
|
case .snapshotFailed(let reason):
|
||||||
|
return "Snapshot failed: \(reason)"
|
||||||
|
case .promoteFailed(let reason):
|
||||||
|
return "Promote failed: \(reason)"
|
||||||
|
case .noPassFiles:
|
||||||
|
return "No pass .ti3 snapshots are available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem helpers for multi-pass measurement artefacts.
|
||||||
|
///
|
||||||
|
/// Pass snapshots use 1-based numbering and are tracked as
|
||||||
|
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
|
||||||
|
/// Finish / Average (docs/24 #109, #110).
|
||||||
|
public enum MeasurementArtefacts {
|
||||||
|
|
||||||
|
/// Find all existing pass snapshots in `cwd`, sorted numerically.
|
||||||
|
public static func passSnapshots(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let suffix = "ti3"
|
||||||
|
|
||||||
|
let passes: [(Int, URL)] = entries.compactMap { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
guard url.pathExtension.lowercased() == suffix,
|
||||||
|
name.hasPrefix(prefix)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
guard let number = Int(numberPart), number > 0 else { return nil }
|
||||||
|
return (number, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return passes
|
||||||
|
.sorted { $0.0 < $1.0 }
|
||||||
|
.map { $0.1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next 1-based pass number.
|
||||||
|
public static func nextPassNumber(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Int {
|
||||||
|
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
|
||||||
|
guard let last = existing.last else { return 1 }
|
||||||
|
let name = last.lastPathComponent
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
return (Int(numberPart) ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
|
||||||
|
///
|
||||||
|
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
|
||||||
|
public static func snapshotPass(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
guard fileManager.fileExists(atPath: canonical.path) else {
|
||||||
|
throw MeasurementArtefactError.canonicalMissing(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
|
||||||
|
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: canonical, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: pass.path) {
|
||||||
|
try? fileManager.removeItem(at: pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: pass)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: canonical)
|
||||||
|
} catch {
|
||||||
|
// The pass file is durable; canonical removal failure is logged but not fatal.
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
|
||||||
|
public static func promotePass(
|
||||||
|
pass: URL,
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: pass.path) else {
|
||||||
|
throw MeasurementArtefactError.noPassFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: pass, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: canonical.path) {
|
||||||
|
try? fileManager.removeItem(at: canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: canonical)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical `<basename>.ti3` URL, regardless of existence.
|
||||||
|
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
return cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@ public enum AppPaths {
|
|||||||
|
|
||||||
/// `~/Library/Application Support/com.gronod.iccery2`
|
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||||
///
|
///
|
||||||
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
/// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
|
||||||
/// against an isolated root and never touch the developer's state.
|
/// data so UI tests run against an isolated root and never touch the
|
||||||
|
/// developer's state.
|
||||||
public static var appDataDir: URL {
|
public static var appDataDir: URL {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let root = testRoot {
|
if let root = testRoot {
|
||||||
@@ -42,11 +43,32 @@ public enum AppPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
/// DEBUG-only root override. Order:
|
||||||
|
/// 1. `ICCERY_TEST_ROOT` for an explicit test root.
|
||||||
|
/// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to
|
||||||
|
/// the current UI test's working directory.
|
||||||
|
/// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test
|
||||||
|
/// that sets neither of the above still runs in isolation.
|
||||||
|
///
|
||||||
|
/// Computed from `ProcessInfo` each call — no mutable static state.
|
||||||
private static var testRoot: URL? {
|
private static var testRoot: URL? {
|
||||||
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||||
!raw.isEmpty else { return nil }
|
!raw.isEmpty {
|
||||||
return URL(fileURLWithPath: raw, isDirectory: true)
|
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"],
|
||||||
|
!raw.isEmpty {
|
||||||
|
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||||
|
return FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"iccery-ui-\(ProcessInfo.processInfo.processIdentifier)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ordered private-SPI attempt table for the ColorSync suppression
|
||||||
|
/// engine (issue 14 layer ②, docs/11).
|
||||||
|
///
|
||||||
|
/// The ordering is data so the exact dlsym/mode sequence is unit-
|
||||||
|
/// testable without resolving any private symbols. The app layer walks
|
||||||
|
/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,…)`, and
|
||||||
|
/// calls the first `(symbol, mode)` that returns `0` — verified on
|
||||||
|
/// macOS 14+ that all three symbols exist.
|
||||||
|
///
|
||||||
|
/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||||
|
/// The second argument is the **mode string**, never integer `1`
|
||||||
|
/// (#188 — a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and
|
||||||
|
/// `AP_VendorColorMatching` are forbidden modes — they re-enable
|
||||||
|
/// ColorSync/driver colour management.
|
||||||
|
public enum ColorMatchingAttempts {
|
||||||
|
|
||||||
|
/// dlsym order: `…Lock` first (holds the print-session lock while
|
||||||
|
/// setting), then the plain setter, then `…NoLock`.
|
||||||
|
public static let symbols: [String] = [
|
||||||
|
"PMSessionSetColorMatchingModeLock",
|
||||||
|
"PMSessionSetColorMatchingMode",
|
||||||
|
"PMSessionSetColorMatchingModeNoLock",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Mode strings tried per symbol, in order. `AP_…` is the
|
||||||
|
/// documented mode; the unprefixed variant is the older alias.
|
||||||
|
public static let modes: [String] = [
|
||||||
|
"AP_ApplicationColorMatching",
|
||||||
|
"ApplicationColorMatching",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Symbol-outer, mode-inner — the full attempt sequence; the app
|
||||||
|
/// stops at the first call that returns `0`.
|
||||||
|
public static var attempts: [(symbol: String, mode: String)] {
|
||||||
|
symbols.flatMap { symbol in
|
||||||
|
modes.map { (symbol: symbol, mode: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layer ③: both spellings of the print-settings key are written
|
||||||
|
/// with `locked = true`. Written as `CFString` values.
|
||||||
|
public static let applicationMatchingValue = "AP_ApplicationColorMatching"
|
||||||
|
public static let printSettingsKeys: [String] = [
|
||||||
|
"AP_ColorMatchingMode",
|
||||||
|
"AP.ColorMatchingMode",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The Quartz/`NSPrintOperation` colour-matching vocabulary
|
||||||
|
/// (docs/14 §7). With the `lp` path removed there is a single
|
||||||
|
/// spool path and it carries **both** dictionaries — these keys
|
||||||
|
/// are written alongside the AP_* pair (#201 D2).
|
||||||
|
///
|
||||||
|
/// `PMColorMatchingMode=APCustomColorMatching` plus an empty
|
||||||
|
/// `PMCustomColorMatchingProfile` tell Quartz the application
|
||||||
|
/// supplies device colour; the `com.apple.print.PrintSettings.*`
|
||||||
|
/// legacy spelling covers drivers that read the flattened
|
||||||
|
/// dictionary.
|
||||||
|
public static let quartzModeKey = "PMColorMatchingMode"
|
||||||
|
public static let quartzCustomMatching = "APCustomColorMatching"
|
||||||
|
public static let quartzProfileKey = "PMCustomColorMatchingProfile"
|
||||||
|
public static let quartzLegacyModeKey =
|
||||||
|
"com.apple.print.PrintSettings.PMColorMatchingMode"
|
||||||
|
/// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the
|
||||||
|
/// colour keys are mirrored into.
|
||||||
|
public static let quartzNestedDictKey = "com.apple.print.printSettings"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CUPS option filtering for `PMPrintSettingsToOptions` capture
|
||||||
|
/// (issue 14 layer ⑥, docs/11 §filter).
|
||||||
|
///
|
||||||
|
/// The captured `key=value` string is reduced to the options that
|
||||||
|
/// should be replayed on `lp`: `com.apple.*` ticket keys, job
|
||||||
|
/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`,
|
||||||
|
/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode`
|
||||||
|
/// keys are dropped — `build_lp_args` always re-adds those itself
|
||||||
|
/// (issue 15). Unknown non-`com.*` keys are kept (permissive — vendor
|
||||||
|
/// driver keys survive).
|
||||||
|
public enum CupsOptionsFilter {
|
||||||
|
|
||||||
|
/// Option keys forwarded from the panel to `lp` (docs/11 roster).
|
||||||
|
public static let relevantKeys: Set<String> = [
|
||||||
|
// Media
|
||||||
|
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
|
||||||
|
// Tray
|
||||||
|
"InputSlot", "MediaSource", "AP_D_InputSlot",
|
||||||
|
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
|
||||||
|
// Size
|
||||||
|
"PageSize",
|
||||||
|
// Colour bypass
|
||||||
|
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
|
||||||
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
|
"EpsonColorMode", "ColorModel",
|
||||||
|
// Quality
|
||||||
|
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||||
|
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||||
|
"PrintQuality", "StpQuality", "OutputMode",
|
||||||
|
// Duplex
|
||||||
|
"Duplex", "sides",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Keys we always drop regardless of the relevant list. `raw` is
|
||||||
|
/// included — a captured `raw=…` would re-enable CUPS raw mode and
|
||||||
|
/// bypass the raster filter that honours `AP_ApplicationColorMatching`
|
||||||
|
/// (#92).
|
||||||
|
public static let alwaysDropped: Set<String> = [
|
||||||
|
"collate", "copies", "pserrorhandler-requested", "job-sheets",
|
||||||
|
"AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// A `key=value` pair survives when the key is non-empty, the value
|
||||||
|
/// is non-empty, the key is not `com.apple.*`, not always-dropped,
|
||||||
|
/// and either relevant or an unknown non-`com.*` driver key.
|
||||||
|
public static func isRelevant(key: String, value: String) -> Bool {
|
||||||
|
guard !key.isEmpty, !value.isEmpty else { return false }
|
||||||
|
if key.hasPrefix("com.apple.") { return false }
|
||||||
|
if alwaysDropped.contains(key) { return false }
|
||||||
|
if relevantKeys.contains(key) { return true }
|
||||||
|
// Permissive: unknown vendor keys survive (non-com.*).
|
||||||
|
return !key.hasPrefix("com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `key=value key=value …` → filtered string, order preserved.
|
||||||
|
public static func filter(_ options: String) -> String {
|
||||||
|
CupsParsers.lpoptions(options)
|
||||||
|
.filter { isRelevant(key: $0.key, value: $0.value) }
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,26 @@ public enum CupsParsers {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
|
||||||
|
/// Entries with no URI (e.g. `network <name>` remote stubs) are
|
||||||
|
/// skipped (#202 AirPrint detection).
|
||||||
|
public static func lpstatDeviceURIs(output: String) -> [String: String] {
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.hasPrefix("device for ") else { continue }
|
||||||
|
let rest = text.dropFirst("device for ".count)
|
||||||
|
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||||
|
let name = String(rest[..<colon])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let uri = String(rest[rest.index(after: colon)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty, !uri.isEmpty else { continue }
|
||||||
|
result[name] = uri
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -p <queue>
|
// MARK: - lpoptions -p <queue>
|
||||||
|
|
||||||
/// `lpoptions -p` — `key=value` pairs, values may be
|
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||||
@@ -91,7 +111,18 @@ public enum CupsParsers {
|
|||||||
index = output.index(after: index)
|
index = output.index(after: index)
|
||||||
}
|
}
|
||||||
let key = String(output[tokenStart..<index])
|
let key = String(output[tokenStart..<index])
|
||||||
guard !key.isEmpty else { break }
|
// A token starting with `=` has no key — skip it (and its
|
||||||
|
// value) rather than truncating the whole parse.
|
||||||
|
guard !key.isEmpty else {
|
||||||
|
if index < output.endIndex && output[index] == "=" {
|
||||||
|
index = output.index(after: index)
|
||||||
|
while index < output.endIndex
|
||||||
|
&& !output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if index < output.endIndex && output[index] == "=" {
|
if index < output.endIndex && output[index] == "=" {
|
||||||
index = output.index(after: index)
|
index = output.index(after: index)
|
||||||
if index < output.endIndex && output[index] == "'" {
|
if index < output.endIndex && output[index] == "'" {
|
||||||
@@ -126,6 +157,16 @@ public enum CupsParsers {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
|
||||||
|
/// output — an AirPrint detection input (#202).
|
||||||
|
public static func lpoptionsMakeAndModel(output: String) -> String? {
|
||||||
|
guard let value = lpoptions(output)
|
||||||
|
.first(where: { $0.key == "printer-make-and-model" })?.value,
|
||||||
|
!value.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -l
|
// MARK: - lpoptions -l
|
||||||
|
|
||||||
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||||
@@ -163,8 +204,13 @@ public enum CupsParsers {
|
|||||||
|
|
||||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||||
|
/// Precedence is deterministic, not positional (#181): unqualified
|
||||||
|
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||||
|
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||||
|
/// base English labels — and a qualified-only id still gets its
|
||||||
|
/// first-seen qualified label (R9).
|
||||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||||
var map: [String: String] = [:]
|
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||||
for rawLine in ppd.split(separator: "\n") {
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
@@ -172,7 +218,9 @@ public enum CupsParsers {
|
|||||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
// Only strip when the part before the first `.` looks like
|
// Only strip when the part before the first `.` looks like
|
||||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||||
// are left alone.
|
// are left alone. The qualifier is recorded for precedence
|
||||||
|
// rather than dropped (#181).
|
||||||
|
var qualifier: String?
|
||||||
if let dot = line.firstIndex(of: ".") {
|
if let dot = line.firstIndex(of: ".") {
|
||||||
let prefix = line[..<dot]
|
let prefix = line[..<dot]
|
||||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
@@ -180,6 +228,7 @@ public enum CupsParsers {
|
|||||||
&& (prefix.count == 2 || prefix.contains("_"))
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
let candidate = line[line.index(after: dot)...]
|
let candidate = line[line.index(after: dot)...]
|
||||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
qualifier = prefix.lowercased()
|
||||||
line = String(candidate)
|
line = String(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,15 +239,50 @@ public enum CupsParsers {
|
|||||||
rest = String(rest[..<colon])
|
rest = String(rest[..<colon])
|
||||||
// `<id>/<Human label>` — human label after the last `/`.
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
let id = String(rest[..<slash])
|
let id = ppdUnescape(String(rest[..<slash])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
let human = String(rest[rest.index(after: slash)...])
|
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
guard !id.isEmpty else { continue }
|
||||||
|
hits[id, default: []].append(
|
||||||
|
(qualifier, human.isEmpty ? id : human))
|
||||||
|
}
|
||||||
|
var map: [String: String] = [:]
|
||||||
|
for (id, candidates) in hits {
|
||||||
|
// First occurrence wins inside each qualifier class, in
|
||||||
|
// file order — same as the old sequential behaviour.
|
||||||
|
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||||
|
?? candidates.first { $0.qualifier == "en" }?.label
|
||||||
|
?? candidates[0].label
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||||
|
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||||
|
/// `>` passes through untouched (#181).
|
||||||
|
private static func ppdUnescape(_ text: String) -> String {
|
||||||
|
var result = ""
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex {
|
||||||
|
guard text[index] == "<",
|
||||||
|
let hexEnd = text.index(
|
||||||
|
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||||
|
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||||
|
let byte = UInt8(
|
||||||
|
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||||
|
else {
|
||||||
|
result.append(text[index])
|
||||||
|
index = text.index(after: index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(Character(UnicodeScalar(byte)))
|
||||||
|
index = text.index(after: hexEnd)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Detection (docs/11)
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
/// Media-type option key in preference order — used both to read a
|
/// Media-type option key in preference order — used both to read a
|
||||||
@@ -212,13 +296,110 @@ public enum CupsParsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media type from a captured `key=value key=value` options string.
|
/// Media type from a captured `key=value key=value` options string.
|
||||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||||
|
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||||
|
/// `StpMediaType` (#186 capture-return).
|
||||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||||
let pairs = lpoptions(options)
|
let pairs = lpoptions(options)
|
||||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
for key in mediaTypeKeys where key != "MediaType" {
|
||||||
|
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print-quality option key in preference order — vendor-first,
|
||||||
|
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
|
||||||
|
/// drivers they are colour-mode keys, not quality (#180).
|
||||||
|
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
|
||||||
|
public static let qualityKeys = [
|
||||||
|
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
|
||||||
|
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
|
||||||
|
"EPIJ_Quality", "OutputMode", "Resolution",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
|
||||||
|
qualityKeys.first { optionKeys.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single value from a captured `key=value key=value` options
|
||||||
|
/// string — case-insensitive key match (#183 capture-return).
|
||||||
|
public static func extractOption(
|
||||||
|
named key: String,
|
||||||
|
fromOptionsString options: String
|
||||||
|
) -> String? {
|
||||||
|
lpoptions(options).first {
|
||||||
|
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||||
|
}?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print-quality token from a captured options string — the queue's
|
||||||
|
/// quality key is detected from the roster before extracting (#183).
|
||||||
|
public static func extractQuality(fromOptionsString options: String) -> String? {
|
||||||
|
let pairs = lpoptions(options)
|
||||||
|
guard let key = detectQualityKey(
|
||||||
|
optionKeys: Set(pairs.map(\.key)))
|
||||||
|
else { return nil }
|
||||||
|
return pairs.first(where: { $0.key == key })?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
|
||||||
|
public static func extractOrientation(fromOptionsString options: String) -> String? {
|
||||||
|
extractOption(named: "orientation-requested", fromOptionsString: options)
|
||||||
|
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tray detection (#218)
|
||||||
|
|
||||||
|
/// Paper-source option key in preference order. `EPIJ_FdSo` is
|
||||||
|
/// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs
|
||||||
|
/// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as
|
||||||
|
/// "Page Setup" — the feed source is what Stage 2 should control.
|
||||||
|
/// `AP_D_InputSlot` is a capture-side alias only.
|
||||||
|
public static let trayKeys = [
|
||||||
|
"InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc",
|
||||||
|
"CNIJMediaSupply"
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func detectTrayKey(optionKeys: Set<String>) -> String? {
|
||||||
|
trayKeys.first { optionKeys.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a paper-source token from a captured `key=value` string.
|
||||||
|
/// If `preferredKey` is supplied and present, it wins; otherwise
|
||||||
|
/// every roster key is tried. `AP_D_InputSlot` is accepted as an
|
||||||
|
/// alias when it carries a non-empty value. Empty values are
|
||||||
|
/// ignored so an `AP_D_InputSlot=` placeholder never maps to a
|
||||||
|
/// selection (#218 capture-back).
|
||||||
|
public static func extractTray(
|
||||||
|
fromOptionsString options: String,
|
||||||
|
preferredKey: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
let pairs = lpoptions(options)
|
||||||
|
if let preferredKey,
|
||||||
|
let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare(preferredKey) == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for key in trayKeys {
|
||||||
|
if let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture-side alias used by some macOS Core Printing paths.
|
||||||
|
if let value = pairs.first(where: {
|
||||||
|
$0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame
|
||||||
|
})?.value, !value.isEmpty {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||||
@@ -250,4 +431,62 @@ public enum CupsParsers {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode",
|
"EpsonColorMode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// MARK: - AirPrint detection (#202, docs/14 §10.2)
|
||||||
|
|
||||||
|
/// AirPrint queues always colour-manage the URF raster path, so
|
||||||
|
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
|
||||||
|
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
|
||||||
|
///
|
||||||
|
/// 1. device URI contains `apple-airprint://`
|
||||||
|
/// 2. PPD declares `*APAirPrint: True`
|
||||||
|
/// 3. make-and-model contains "Apple" **and** "AirPrint"
|
||||||
|
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// (case-insensitive)
|
||||||
|
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
|
||||||
|
/// subtype (`_universal._sub._ipp._tcp`)
|
||||||
|
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
|
||||||
|
/// type is `image/urf` — the raster only AirPrint consumes
|
||||||
|
public static func detectAirPrint(
|
||||||
|
deviceURI: String?, makeAndModel: String?, ppd: String
|
||||||
|
) -> Bool {
|
||||||
|
if let uri = deviceURI {
|
||||||
|
let lower = uri.lowercased()
|
||||||
|
if lower.contains("apple-airprint://") { return true }
|
||||||
|
if lower.hasPrefix("ipps://"),
|
||||||
|
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("ipp://"),
|
||||||
|
lower.contains("_universal._sub._ipp._tcp") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ppd.contains("*APAirPrint: True") { return true }
|
||||||
|
if let model = makeAndModel,
|
||||||
|
model.contains("Apple"), model.contains("AirPrint") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ppdCarriesURFFilter(ppd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
|
||||||
|
/// optional); `image/urf` as the destination token means the queue
|
||||||
|
/// consumes the AirPrint raster.
|
||||||
|
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
|
||||||
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.lowercased().hasPrefix("*cupsfilter2"),
|
||||||
|
let colon = line.firstIndex(of: ":")
|
||||||
|
else { continue }
|
||||||
|
let tokens = line[line.index(after: colon)...]
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
.split(separator: " ")
|
||||||
|
if tokens.count >= 2,
|
||||||
|
tokens[1].lowercased() == "image/urf" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Foundation
|
|||||||
/// Errors from CUPS tool invocations.
|
/// Errors from CUPS tool invocations.
|
||||||
public enum CupsError: LocalizedError, Equatable {
|
public enum CupsError: LocalizedError, Equatable {
|
||||||
case toolFailed(tool: String, code: Int32, stderr: String)
|
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||||
case tiffMissing(String)
|
case noPrinterSelected
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -12,8 +12,8 @@ public enum CupsError: LocalizedError, Equatable {
|
|||||||
return detail.isEmpty
|
return detail.isEmpty
|
||||||
? "\(tool) failed with exit code \(code)"
|
? "\(tool) failed with exit code \(code)"
|
||||||
: "\(tool) failed (\(code)): \(detail)"
|
: "\(tool) failed (\(code)): \(detail)"
|
||||||
case .tiffMissing(let path):
|
case .noPrinterSelected:
|
||||||
return "Target TIFF does not exist: \(path)"
|
return "No printer selected."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable {
|
|||||||
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
||||||
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
||||||
/// injectable so tests use fixture scripts and never touch real CUPS.
|
/// injectable so tests use fixture scripts and never touch real CUPS.
|
||||||
|
/// Target spooling is **not** here — it is the app-target
|
||||||
|
/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4).
|
||||||
public struct CupsService: Sendable {
|
public struct CupsService: Sendable {
|
||||||
public let processManager: ProcessManager
|
public let processManager: ProcessManager
|
||||||
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
|
/// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in
|
||||||
/// production, a fixture dir under test.
|
/// production, a fixture dir under test.
|
||||||
public let binaryDir: URL
|
public let binaryDir: URL
|
||||||
/// `/etc/cups/ppd` in production.
|
/// `/etc/cups/ppd` in production.
|
||||||
@@ -50,24 +52,34 @@ public struct CupsService: Sendable {
|
|||||||
// lpstat exits non-zero when no destinations exist — an empty
|
// lpstat exits non-zero when no destinations exist — an empty
|
||||||
// queue list is a valid result, not a failure (issue 12).
|
// queue list is a valid result, not a failure (issue 12).
|
||||||
let destinationsOut = try await run(
|
let destinationsOut = try await run(
|
||||||
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
|
"lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true)
|
||||||
let statusOut = try await run(
|
let statusOut = try await run(
|
||||||
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
|
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
||||||
let defaultOut = try await run(
|
let defaultOut = try await run(
|
||||||
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
|
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
||||||
|
// One-shot device-URI fetch for AirPrint detection (#202). A
|
||||||
|
// failed `lpstat -v` is tolerated — enumeration proceeds with
|
||||||
|
// no URIs and no fallback respawn.
|
||||||
|
let deviceOut = try await run(
|
||||||
|
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
|
||||||
|
|
||||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||||
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||||
|
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
|
||||||
|
|
||||||
var printers: [Printer] = []
|
var printers: [Printer] = []
|
||||||
for name in names {
|
for name in names {
|
||||||
let displayName = try? await displayName(for: name)
|
let identity = try? await queueIdentity(for: name)
|
||||||
printers.append(Printer(
|
printers.append(Printer(
|
||||||
name: name,
|
name: name,
|
||||||
status: statuses[name] ?? .unknown,
|
status: statuses[name] ?? .unknown,
|
||||||
isDefault: name == defaultName,
|
isDefault: name == defaultName,
|
||||||
displayName: displayName
|
displayName: identity?.displayName,
|
||||||
|
isAirPrint: CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: deviceURIs[name],
|
||||||
|
makeAndModel: identity?.makeAndModel,
|
||||||
|
ppd: loadPPD(for: name) ?? "")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return printers
|
return printers
|
||||||
@@ -76,9 +88,19 @@ public struct CupsService: Sendable {
|
|||||||
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||||
/// display name, docs/11 §binding).
|
/// display name, docs/11 §binding).
|
||||||
public func displayName(for queue: String) async throws -> String? {
|
public func displayName(for queue: String) async throws -> String? {
|
||||||
|
try await queueIdentity(for: queue).displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `lpoptions -p <queue>` spawn yields both identity fields —
|
||||||
|
/// `printer-info` (display name) and `printer-make-and-model`
|
||||||
|
/// (AirPrint rule 3, #202).
|
||||||
|
private func queueIdentity(
|
||||||
|
for queue: String
|
||||||
|
) async throws -> (displayName: String?, makeAndModel: String?) {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
|
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
||||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
return (CupsParsers.lpoptionsDisplayName(result.stdout),
|
||||||
|
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Capabilities (lpoptions -l + PPD)
|
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||||
@@ -87,15 +109,42 @@ public struct CupsService: Sendable {
|
|||||||
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
||||||
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
|
"lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(queue)-l"))
|
||||||
return CupsParsers.lpoptionsList(result.stdout)
|
return CupsParsers.lpoptionsList(result.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trays / paper sizes / media types for a queue, with PPD
|
/// Trays / paper sizes / media types for a queue, with PPD
|
||||||
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
||||||
|
/// Also resolves the per-media quality map from vendor driver
|
||||||
|
/// data (`MediaQualityConstraints`, #214) — empty when the driver
|
||||||
|
/// exposes none.
|
||||||
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
||||||
let listings = try await optionListings(for: queue)
|
let listings = try await optionListings(for: queue)
|
||||||
return capabilities(from: listings, ppd: loadPPD(for: queue))
|
let ppd = loadPPD(for: queue)
|
||||||
|
var caps = capabilities(from: listings, ppd: ppd)
|
||||||
|
// Default queue-level map, then per-tray overrides (#218).
|
||||||
|
// Share one file-read cache across all contexts so multiple
|
||||||
|
// tray-token resolutions read Epson PDEData.dat only once.
|
||||||
|
var fileCache: [URL: Data] = [:]
|
||||||
|
let readFile: (URL) -> Data? = {
|
||||||
|
if let cached = fileCache[$0] { return cached }
|
||||||
|
guard let data = try? Data(contentsOf: $0) else { return nil }
|
||||||
|
fileCache[$0] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
|
||||||
|
listings: listings, ppd: ppd,
|
||||||
|
optionOverrides: [:], readFile: readFile)
|
||||||
|
if let trayKey = caps.trayKey {
|
||||||
|
for tray in caps.trays {
|
||||||
|
let map = MediaQualityConstraints.resolve(
|
||||||
|
listings: listings, ppd: ppd,
|
||||||
|
optionOverrides: [trayKey: tray.token],
|
||||||
|
readFile: readFile)
|
||||||
|
caps.qualityIDsByMediaTypeByTray[tray.token] = map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure mapping — extracted so fixture tests need no process.
|
/// Pure mapping — extracted so fixture tests need no process.
|
||||||
@@ -103,16 +152,12 @@ public struct CupsService: Sendable {
|
|||||||
from listings: [CupsOptionListing],
|
from listings: [CupsOptionListing],
|
||||||
ppd: String?
|
ppd: String?
|
||||||
) -> PrinterCapabilities {
|
) -> PrinterCapabilities {
|
||||||
var trays: [PrinterTray] = []
|
let optionKeys = Set(listings.map(\.key))
|
||||||
var sizes: [PrinterPaperSize] = []
|
var sizes: [PrinterPaperSize] = []
|
||||||
var media: [PrinterMediaType] = []
|
var media: [PrinterMediaType] = []
|
||||||
|
|
||||||
for listing in listings {
|
for listing in listings {
|
||||||
switch listing.key {
|
switch listing.key {
|
||||||
case "InputSlot", "MediaSource":
|
|
||||||
trays = listing.choices.enumerated().map {
|
|
||||||
PrinterTray(id: $0.offset + 1, name: $0.element)
|
|
||||||
}
|
|
||||||
case "PageSize", "MediaSize":
|
case "PageSize", "MediaSize":
|
||||||
sizes = listing.choices.enumerated().map {
|
sizes = listing.choices.enumerated().map {
|
||||||
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
||||||
@@ -129,8 +174,49 @@ public struct CupsService: Sendable {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paper source — one deterministic vendor roster key (#218).
|
||||||
|
let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys)
|
||||||
|
let trays: [PrinterTray]
|
||||||
|
let trayDefault: String?
|
||||||
|
if let trayKey,
|
||||||
|
let listing = listings.first(where: { $0.key == trayKey }) {
|
||||||
|
let labels = ppd.map {
|
||||||
|
CupsParsers.ppdChoiceLabels($0, key: trayKey)
|
||||||
|
} ?? [:]
|
||||||
|
trays = listing.choices.enumerated().map {
|
||||||
|
PrinterTray(
|
||||||
|
id: $0.offset + 1,
|
||||||
|
token: $0.element,
|
||||||
|
name: labels[$0.element] ?? $0.element)
|
||||||
|
}
|
||||||
|
trayDefault = listing.defaultChoice
|
||||||
|
} else {
|
||||||
|
trays = []
|
||||||
|
trayDefault = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print quality — the detected roster key's listing maps to
|
||||||
|
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||||
|
var qualities: [PrinterQuality] = []
|
||||||
|
var qualityDefault: String?
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
|
||||||
|
if let qualityKey,
|
||||||
|
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||||
|
let labels = ppd.map {
|
||||||
|
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
|
||||||
|
} ?? [:]
|
||||||
|
qualities = listing.choices.map {
|
||||||
|
PrinterQuality(id: $0, name: labels[$0] ?? $0)
|
||||||
|
}
|
||||||
|
qualityDefault = listing.defaultChoice
|
||||||
|
}
|
||||||
|
|
||||||
return PrinterCapabilities(
|
return PrinterCapabilities(
|
||||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||||
|
trayKey: trayKey, qualityKey: qualityKey,
|
||||||
|
qualities: qualities, trayDefault: trayDefault,
|
||||||
|
qualityDefault: qualityDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of option keys a queue advertises — input to
|
/// The set of option keys a queue advertises — input to
|
||||||
@@ -143,7 +229,11 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||||
return try? String(contentsOf: url, encoding: .utf8)
|
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||||
|
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||||
|
// when UTF-8 decoding fails outright.
|
||||||
|
return (try? String(contentsOf: url, encoding: .utf8))
|
||||||
|
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn
|
// MARK: - Spawn
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Per-media quality validity (#214): which of the queue's quality
|
||||||
|
/// tokens the driver actually accepts for a given media type. The
|
||||||
|
/// answer is not in `lpoptions -l`, IPP, or standard PPD
|
||||||
|
/// `*UIConstraints` for the two driver families handled here —
|
||||||
|
///
|
||||||
|
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
|
||||||
|
/// lines in the machine bundle's
|
||||||
|
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
|
||||||
|
/// choice while every LHS `*<key> <value>` term holds. LHS keys
|
||||||
|
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
|
||||||
|
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
|
||||||
|
/// defaults — reproducing the driver's PDE state when it opens.
|
||||||
|
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
|
||||||
|
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
|
||||||
|
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
|
||||||
|
/// u16 0, u16 quality}` records enumerate the allowed
|
||||||
|
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
|
||||||
|
/// `| 0x10000` marks the borderless variant of the same media).
|
||||||
|
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
|
||||||
|
/// (Gutenprint etc.) — first non-empty source wins.
|
||||||
|
///
|
||||||
|
/// Every parser is pure; file access is injected so tests need no
|
||||||
|
/// installed driver. Failure or absence yields an empty map — callers
|
||||||
|
/// treat that as "unconstrained" and keep the full quality list.
|
||||||
|
public enum MediaQualityConstraints {
|
||||||
|
|
||||||
|
/// media-type id → allowed quality ids. Sources tried in order
|
||||||
|
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
|
||||||
|
/// the first source producing any entries wins.
|
||||||
|
public static func resolve(
|
||||||
|
listings: [CupsOptionListing],
|
||||||
|
ppd: String?,
|
||||||
|
optionOverrides: [String: String] = [:],
|
||||||
|
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
let optionKeys = Set(listings.map(\.key))
|
||||||
|
guard let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys),
|
||||||
|
let qualityKey = CupsParsers.detectQualityKey(
|
||||||
|
optionKeys: optionKeys),
|
||||||
|
let mediaListing = listings.first(where: {
|
||||||
|
$0.key == mediaKey }),
|
||||||
|
let qualityListing = listings.first(where: {
|
||||||
|
$0.key == qualityKey })
|
||||||
|
else { return [:] }
|
||||||
|
let mediaIDs = Set(mediaListing.choices)
|
||||||
|
let qualityIDs = Set(qualityListing.choices)
|
||||||
|
var defaults = Dictionary(
|
||||||
|
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
|
||||||
|
uniquingKeysWith: { first, _ in first })
|
||||||
|
for (key, value) in optionOverrides {
|
||||||
|
defaults[key] = value
|
||||||
|
}
|
||||||
|
guard let ppd else { return [:] }
|
||||||
|
|
||||||
|
let ppdMap = allowedMap(
|
||||||
|
forbidden: ppdUIConstraints(
|
||||||
|
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !ppdMap.isEmpty { return ppdMap }
|
||||||
|
|
||||||
|
if let url = epijPDEDataPath(ppd: ppd),
|
||||||
|
let data = readFile(url),
|
||||||
|
let dat = String(data: data, encoding: .utf8)
|
||||||
|
?? String(data: data, encoding: .isoLatin1) {
|
||||||
|
let map = allowedMap(
|
||||||
|
forbidden: epijUIConstraints(
|
||||||
|
dat, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
|
||||||
|
defaults: defaults),
|
||||||
|
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !map.isEmpty { return map }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = cnijTablePath(ppd: ppd),
|
||||||
|
let data = readFile(url) {
|
||||||
|
let map = cnijMediaQualityTable(
|
||||||
|
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||||
|
if !map.isEmpty { return map }
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PPD keyword lookup
|
||||||
|
|
||||||
|
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
|
||||||
|
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
|
||||||
|
/// for `CNIJTableID`.
|
||||||
|
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
|
||||||
|
for raw in ppd.split(separator: "\n") {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("*\(key)") else { continue }
|
||||||
|
let rest = line.dropFirst(key.count + 1)
|
||||||
|
guard rest.first == ":" else { continue }
|
||||||
|
var value = rest.dropFirst()
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if value.count >= 2,
|
||||||
|
value.hasPrefix("\""), value.hasSuffix("\"") {
|
||||||
|
value = String(value.dropFirst().dropLast())
|
||||||
|
}
|
||||||
|
if !value.isEmpty { return value }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic PPD constraints
|
||||||
|
|
||||||
|
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
|
||||||
|
/// option choices; the ones naming both the media key and the
|
||||||
|
/// quality key produce forbidden (media, quality) pairs.
|
||||||
|
/// `*cupsUIConstraints` resolver triples do not match the
|
||||||
|
/// `*…Constraints:` prefixes, so they are skipped naturally.
|
||||||
|
public static func ppdUIConstraints(
|
||||||
|
_ ppd: String,
|
||||||
|
mediaKey: String,
|
||||||
|
qualityKey: String,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var forbidden: [String: Set<String>] = [:]
|
||||||
|
for raw in ppd.split(separator: "\n") {
|
||||||
|
var line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("*UIConstraints:") {
|
||||||
|
line = String(line.dropFirst("*UIConstraints:".count))
|
||||||
|
} else if line.hasPrefix("*Constraints:") {
|
||||||
|
line = String(line.dropFirst("*Constraints:".count))
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let terms = constraintTerms(line)
|
||||||
|
guard let media = terms[mediaKey], mediaIDs.contains(media),
|
||||||
|
let quality = terms[qualityKey],
|
||||||
|
qualityIDs.contains(quality)
|
||||||
|
else { continue }
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
}
|
||||||
|
return forbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Epson PDEData.dat
|
||||||
|
|
||||||
|
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
|
||||||
|
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
|
||||||
|
/// InkjetPrinter2 family).
|
||||||
|
public static func epijPDEDataPath(ppd: String) -> URL? {
|
||||||
|
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
|
||||||
|
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
|
||||||
|
else { return nil }
|
||||||
|
return URL(fileURLWithPath: base)
|
||||||
|
.appendingPathComponent("Machine")
|
||||||
|
.appendingPathComponent(bundle)
|
||||||
|
.appendingPathComponent("Contents/Resources/PDEData.dat")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
|
||||||
|
/// pairs the driver greys out. A rule *fires* when its LHS
|
||||||
|
/// `*<mediaKey>` term equals the media (absent media term →
|
||||||
|
/// applies to every media) and every other LHS term's value
|
||||||
|
/// equals that key's `lpoptions` default. A term whose key the
|
||||||
|
/// queue does not advertise counts as satisfied — hiding a usable
|
||||||
|
/// quality is a soft restriction, while showing an invalid one
|
||||||
|
/// re-creates the reported print failure.
|
||||||
|
public static func epijUIConstraints(
|
||||||
|
_ dat: String,
|
||||||
|
mediaKey: String,
|
||||||
|
qualityKey: String,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>,
|
||||||
|
defaults: [String: String]
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var forbidden: [String: Set<String>] = [:]
|
||||||
|
for raw in dat.split(separator: "\n") {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("*EPIJUIConstraint:"),
|
||||||
|
let bar = line.firstIndex(of: "|")
|
||||||
|
else { continue }
|
||||||
|
let rhs = constraintTerms(
|
||||||
|
String(line[line.index(after: bar)...]))
|
||||||
|
guard let quality = rhs[qualityKey],
|
||||||
|
qualityIDs.contains(quality)
|
||||||
|
else { continue }
|
||||||
|
let lhs = constraintTerms(
|
||||||
|
String(line[
|
||||||
|
line.index(
|
||||||
|
line.startIndex,
|
||||||
|
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
|
||||||
|
// Non-media terms must hold at the queue's defaults;
|
||||||
|
// a key absent from `defaults` is satisfied (see above).
|
||||||
|
let fires = lhs.allSatisfy { key, value in
|
||||||
|
key == mediaKey || value.isEmpty
|
||||||
|
|| (defaults[key].map { $0 == value } ?? true)
|
||||||
|
}
|
||||||
|
guard fires else { continue }
|
||||||
|
if let media = lhs[mediaKey] {
|
||||||
|
guard mediaIDs.contains(media) else { continue }
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
} else {
|
||||||
|
for media in mediaIDs {
|
||||||
|
forbidden[media, default: []].insert(quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return forbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Canon cnb table
|
||||||
|
|
||||||
|
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
|
||||||
|
public static func cnijTablePath(ppd: String) -> URL? {
|
||||||
|
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
|
||||||
|
let tableID = ppdKeyword(ppd, "CNIJTableID")
|
||||||
|
else { return nil }
|
||||||
|
return URL(fileURLWithPath: dir)
|
||||||
|
.appendingPathComponent("cnb_\(tableID)0.tbl")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans the Canon table for 20-byte LE records anchored on
|
||||||
|
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
|
||||||
|
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
|
||||||
|
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
|
||||||
|
/// listed media id, and `quality` is a listed quality id — so
|
||||||
|
/// unrelated tables cannot inject false entries. Records carry a
|
||||||
|
/// `family` field (0x03 on the Pro9500 II); the modal family is
|
||||||
|
/// used so sibling record layouts in the same file are ignored.
|
||||||
|
/// Returns allowed (not forbidden) sets directly.
|
||||||
|
public static func cnijMediaQualityTable(
|
||||||
|
_ data: Data,
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
|
||||||
|
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
|
||||||
|
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00]
|
||||||
|
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
|
||||||
|
data.withUnsafeBytes { buffer in
|
||||||
|
guard let base = buffer.baseAddress else { return }
|
||||||
|
var i = 0
|
||||||
|
while i + 20 <= buffer.count {
|
||||||
|
if memcmp(base + i, anchor, anchor.count) == 0 {
|
||||||
|
let family = readU16(base, i + 8)
|
||||||
|
let media = readU32(base, i + 12)
|
||||||
|
let pad = readU16(base, i + 16)
|
||||||
|
let quality = readU16(base, i + 18)
|
||||||
|
let baseMedia = media & 0xFFFF
|
||||||
|
if pad == 0,
|
||||||
|
mediaNums.contains(baseMedia),
|
||||||
|
qualityNums.contains(quality) {
|
||||||
|
rows[family, default: []].append(
|
||||||
|
(baseMedia, quality))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
|
||||||
|
else { return [:] }
|
||||||
|
var map: [String: Set<String>] = [:]
|
||||||
|
for row in dominant.value {
|
||||||
|
map[String(row.media), default: []].insert(String(row.quality))
|
||||||
|
}
|
||||||
|
// Keep strict subsets only — a media with the full set (or an
|
||||||
|
// empty one) is unconstrained as far as the UI is concerned.
|
||||||
|
map = map.filter { _, ids in
|
||||||
|
!ids.isEmpty && ids != qualityIDs
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared
|
||||||
|
|
||||||
|
/// `*<key> <value>` term extraction for both PPD and Epson
|
||||||
|
/// constraint syntax. A `*<key>` not followed by a value is a
|
||||||
|
/// wildcard term (empty-string value → always satisfied).
|
||||||
|
private static func constraintTerms(_ text: String) -> [String: String] {
|
||||||
|
var terms: [String: String] = [:]
|
||||||
|
let tokens = text.split(separator: " ").map(String.init)
|
||||||
|
var index = 0
|
||||||
|
while index < tokens.count {
|
||||||
|
guard tokens[index].hasPrefix("*") else {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let key = String(tokens[index].dropFirst())
|
||||||
|
if index + 1 < tokens.count,
|
||||||
|
!tokens[index + 1].hasPrefix("*") {
|
||||||
|
terms[key] = tokens[index + 1]
|
||||||
|
index += 2
|
||||||
|
} else {
|
||||||
|
terms[key] = ""
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// forbidden (media → quality ids) → allowed map entries,
|
||||||
|
/// emitting only strict non-empty subsets: a media whose allowed
|
||||||
|
/// set is empty or equals the full roster is unconstrained as far
|
||||||
|
/// as the picker is concerned.
|
||||||
|
private static func allowedMap(
|
||||||
|
forbidden: [String: Set<String>],
|
||||||
|
mediaIDs: Set<String>,
|
||||||
|
qualityIDs: Set<String>
|
||||||
|
) -> [String: Set<String>] {
|
||||||
|
var map: [String: Set<String>] = [:]
|
||||||
|
for media in mediaIDs {
|
||||||
|
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
|
||||||
|
if !allowed.isEmpty, allowed != qualityIDs {
|
||||||
|
map[media] = allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
|
||||||
|
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||||
|
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
|
||||||
|
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
|
||||||
|
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,28 +17,48 @@ public struct Printer: Codable, Equatable, Sendable {
|
|||||||
public var status: PrinterStatus
|
public var status: PrinterStatus
|
||||||
public var isDefault: Bool
|
public var isDefault: Bool
|
||||||
public var displayName: String?
|
public var displayName: String?
|
||||||
|
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
|
||||||
|
/// colour-managed, so Stage 2 shows a persistent warning badge.
|
||||||
|
public var isAirPrint: Bool = false
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
status: PrinterStatus = .unknown,
|
status: PrinterStatus = .unknown,
|
||||||
isDefault: Bool = false,
|
isDefault: Bool = false,
|
||||||
displayName: String? = nil
|
displayName: String? = nil,
|
||||||
|
isAirPrint: Bool = false
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.status = status
|
self.status = status
|
||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.isAirPrint = isAirPrint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `isAirPrint` predates #202 payloads — a missing key decodes as
|
||||||
|
/// `false` instead of failing the whole decode.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
status = try c.decode(PrinterStatus.self, forKey: .status)
|
||||||
|
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
||||||
/// `MediaSource` choice (not a PPD code) — docs/10.
|
/// `MediaSource` / vendor choice (not a PPD code); `token` is the
|
||||||
|
/// exact CUPS/PPD machine value written to the ticket; `name` is the
|
||||||
|
/// PPD-enriched display label (#218).
|
||||||
public struct PrinterTray: Codable, Equatable, Sendable {
|
public struct PrinterTray: Codable, Equatable, Sendable {
|
||||||
public var id: Int
|
public var id: Int
|
||||||
|
public var token: String
|
||||||
public var name: String
|
public var name: String
|
||||||
|
|
||||||
public init(id: Int, name: String) {
|
public init(id: Int, token: String, name: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.token = token
|
||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +86,18 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
|
||||||
|
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
|
||||||
|
public struct PrinterQuality: Codable, Equatable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
|
||||||
|
public init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||||
public var trays: [PrinterTray]
|
public var trays: [PrinterTray]
|
||||||
public var paperSizes: [PrinterPaperSize]
|
public var paperSizes: [PrinterPaperSize]
|
||||||
@@ -73,35 +105,122 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
|||||||
/// Always `true` on macOS (spec parity — CUPS honours
|
/// Always `true` on macOS (spec parity — CUPS honours
|
||||||
/// `orientation-requested`).
|
/// `orientation-requested`).
|
||||||
public var supportsOrientation: Bool
|
public var supportsOrientation: Bool
|
||||||
|
/// The queue's detected tray enumeration key
|
||||||
|
/// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218).
|
||||||
|
public var trayKey: String?
|
||||||
|
/// The queue's detected quality enumeration key
|
||||||
|
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||||
|
public var qualityKey: String?
|
||||||
|
public var qualities: [PrinterQuality]
|
||||||
|
/// The `*`-marked default tray token from `lpoptions -l`, if any
|
||||||
|
/// (#218).
|
||||||
|
public var trayDefault: String?
|
||||||
|
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||||
|
public var qualityDefault: String?
|
||||||
|
/// Media-type id → the quality ids the driver accepts for it
|
||||||
|
/// (#214 — `MediaQualityConstraints`). Empty when the driver
|
||||||
|
/// exposes no per-media quality matrix; a missing entry means
|
||||||
|
/// "unconstrained" — show everything.
|
||||||
|
public var qualityIDsByMediaType: [String: Set<String>]
|
||||||
|
/// Tray token → media-type id → allowed quality ids. Computed
|
||||||
|
/// once at capability load; a missing tray falls back to
|
||||||
|
/// `qualityIDsByMediaType` (#218).
|
||||||
|
public var qualityIDsByMediaTypeByTray: [String: [String: Set<String>]]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
trays: [PrinterTray] = [],
|
trays: [PrinterTray] = [],
|
||||||
paperSizes: [PrinterPaperSize] = [],
|
paperSizes: [PrinterPaperSize] = [],
|
||||||
mediaTypes: [PrinterMediaType] = [],
|
mediaTypes: [PrinterMediaType] = [],
|
||||||
supportsOrientation: Bool = true
|
supportsOrientation: Bool = true,
|
||||||
|
trayKey: String? = nil,
|
||||||
|
qualityKey: String? = nil,
|
||||||
|
qualities: [PrinterQuality] = [],
|
||||||
|
trayDefault: String? = nil,
|
||||||
|
qualityDefault: String? = nil,
|
||||||
|
qualityIDsByMediaType: [String: Set<String>] = [:],
|
||||||
|
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
|
||||||
) {
|
) {
|
||||||
self.trays = trays
|
self.trays = trays
|
||||||
self.paperSizes = paperSizes
|
self.paperSizes = paperSizes
|
||||||
self.mediaTypes = mediaTypes
|
self.mediaTypes = mediaTypes
|
||||||
self.supportsOrientation = supportsOrientation
|
self.supportsOrientation = supportsOrientation
|
||||||
|
self.trayKey = trayKey
|
||||||
|
self.qualityKey = qualityKey
|
||||||
|
self.qualities = qualities
|
||||||
|
self.trayDefault = trayDefault
|
||||||
|
self.qualityDefault = qualityDefault
|
||||||
|
self.qualityIDsByMediaType = qualityIDsByMediaType
|
||||||
|
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quality map for the requested tray token, if any.
|
||||||
|
/// Prefers the tray-specific map; otherwise falls back to the
|
||||||
|
/// queue-default map. An explicitly empty tray map means the tray
|
||||||
|
/// is unconstrained, so `nil` is returned only when neither map
|
||||||
|
/// exists.
|
||||||
|
private func qualityMap(
|
||||||
|
forTrayToken trayToken: String?
|
||||||
|
) -> [String: Set<String>]? {
|
||||||
|
if let trayToken,
|
||||||
|
let map = qualityIDsByMediaTypeByTray[trayToken],
|
||||||
|
!map.isEmpty {
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qualities valid for `mediaID` and an optional tray token,
|
||||||
|
/// preserving the driver's listing order (#180/#218). Falls back
|
||||||
|
/// to the full list when the driver has no per-media data, the
|
||||||
|
/// media is absent from the map, or the filter would empty the
|
||||||
|
/// picker (#214).
|
||||||
|
public func qualities(
|
||||||
|
forMediaType mediaID: String?,
|
||||||
|
trayToken: String? = nil
|
||||||
|
) -> [PrinterQuality] {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
|
||||||
|
!allowed.isEmpty
|
||||||
|
else { return qualities }
|
||||||
|
let filtered = qualities.filter { allowed.contains($0.id) }
|
||||||
|
return filtered.isEmpty ? qualities : filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `qualityID` is usable with `mediaID` and an optional
|
||||||
|
/// tray token (#218). A quality absent from `qualities` entirely
|
||||||
|
/// is still allowed: a driver-captured token can be legitimate
|
||||||
|
/// even when `lpoptions` never listed it.
|
||||||
|
public func allowsQuality(
|
||||||
|
_ qualityID: String,
|
||||||
|
forMediaType mediaID: String?,
|
||||||
|
trayToken: String? = nil
|
||||||
|
) -> Bool {
|
||||||
|
guard let mediaID,
|
||||||
|
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
|
||||||
|
!allowed.isEmpty
|
||||||
|
else { return true }
|
||||||
|
return allowed.contains(qualityID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
|
/// The Stage 2 mirror — panel selections and the captured `k=v`
|
||||||
/// `paperSource` is ignored unless already present inside captured
|
/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path
|
||||||
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
|
/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6)
|
||||||
/// it on OK) but never gates the argv — macOS always bypasses driver
|
/// and the mirror apply-back; the opaque vendor state now travels
|
||||||
/// colour management.
|
/// inside the `PrintTicket`, not a flattened option string.
|
||||||
public struct PrintOptions: Codable, Equatable, Sendable {
|
public struct PrintOptions: Codable, Equatable, Sendable {
|
||||||
public var paperSource: Int?
|
public var paperSource: Int?
|
||||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||||
public var orientation: String?
|
public var orientation: String?
|
||||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
/// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`.
|
||||||
public var paperSize: String?
|
public var paperSize: String?
|
||||||
public var mediaType: String?
|
public var mediaType: String?
|
||||||
|
/// Print-quality token → `<detectedQualityKey>=` ticket write.
|
||||||
|
public var quality: String?
|
||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
||||||
|
/// the Stage 2 mirror only, never an `lp` payload.
|
||||||
public var cupsOptions: String?
|
public var cupsOptions: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -109,6 +228,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
orientation: String? = nil,
|
orientation: String? = nil,
|
||||||
paperSize: String? = nil,
|
paperSize: String? = nil,
|
||||||
mediaType: String? = nil,
|
mediaType: String? = nil,
|
||||||
|
quality: String? = nil,
|
||||||
ppdUncorrectedPassthrough: Bool? = nil,
|
ppdUncorrectedPassthrough: Bool? = nil,
|
||||||
cupsOptions: String? = nil
|
cupsOptions: String? = nil
|
||||||
) {
|
) {
|
||||||
@@ -116,6 +236,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
self.paperSize = paperSize
|
self.paperSize = paperSize
|
||||||
self.mediaType = mediaType
|
self.mediaType = mediaType
|
||||||
|
self.quality = quality
|
||||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||||
self.cupsOptions = cupsOptions
|
self.cupsOptions = cupsOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)" }
|
||||||
@@ -16,8 +18,8 @@ public enum ProcessID {
|
|||||||
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||||
|
|
||||||
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
||||||
/// streaming Argyll children.
|
/// streaming Argyll children. Named `cups*` so no `lp*`-prefixed
|
||||||
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
/// identifier survives the target-print removal (#201).
|
||||||
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||||
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
|
public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
|
|||||||
return rest.isEmpty ? nil : Self.decode(rest)
|
return rest.isEmpty ? nil : Self.decode(rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail as a single line and clears it.
|
||||||
|
/// Used by `ProcessManager.flushPartialLine` for tools that emit
|
||||||
|
/// progress dots without newlines.
|
||||||
|
public mutating func flushPartial() -> String? {
|
||||||
|
guard !pending.isEmpty else { return nil }
|
||||||
|
var rest = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||||
|
let text = Self.decode(rest)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
|
||||||
private static func decode(_ bytes: Data.SubSequence) -> String {
|
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||||
String(decoding: bytes, as: UTF8.self)
|
String(decoding: bytes, as: UTF8.self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Captured output from `runCaptured` (used by printcal/applycal —
|
/// Captured output from `runCaptured` — one-shot tools whose results
|
||||||
/// the only tools whose results arrive as one-shot output).
|
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
|
||||||
public struct CapturedResult: Sendable, Equatable {
|
public struct CapturedResult: Sendable, Equatable {
|
||||||
public let stdout: String
|
public let stdout: String
|
||||||
public let stderr: String
|
public let stderr: String
|
||||||
@@ -20,8 +20,11 @@ public struct CapturedResult: Sendable, Equatable {
|
|||||||
/// with the prefix stripped; all other stdout is `stdout` events.
|
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||||
/// - `exit` is emitted exactly once per child, and only after both
|
/// - `exit` is emitted exactly once per child, and only after both
|
||||||
/// output pipes reach EOF — so no buffered output is lost on fast
|
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||||
/// exits or kills.
|
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
|
||||||
/// - `kill` drops the stdin handle so writers fail fast.
|
/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
|
||||||
|
/// terminating. Hooks are removed once the child finalizes.
|
||||||
|
/// - `killAll` on `NSApplication.willTerminate` and last-window close
|
||||||
|
/// runs all hooks and terminates every child (#147, #149).
|
||||||
public actor ProcessManager {
|
public actor ProcessManager {
|
||||||
|
|
||||||
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||||
@@ -92,12 +95,18 @@ public actor ProcessManager {
|
|||||||
/// pipes have also reached EOF.
|
/// pipes have also reached EOF.
|
||||||
var pendingExitCode: Int32?
|
var pendingExitCode: Int32?
|
||||||
var finalized = false
|
var finalized = false
|
||||||
|
/// Watchdog that forces finalization if EOFs never arrive.
|
||||||
|
var finalizeTask: Task<Void, Never>?
|
||||||
}
|
}
|
||||||
|
|
||||||
private var children: [String: RunningChild] = [:]
|
private var children: [String: RunningChild] = [:]
|
||||||
/// Processes owned by `runCaptured` (dup detection + kill support).
|
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||||
private var captured: [String: Process] = [:]
|
private var captured: [String: Process] = [:]
|
||||||
|
|
||||||
|
/// Hooks run by `kill` before terminating the child.
|
||||||
|
/// Used by `chartread` to park an XY head with `q\n`.
|
||||||
|
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
|
||||||
|
|
||||||
/// Ids of currently-running children.
|
/// Ids of currently-running children.
|
||||||
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||||
|
|
||||||
@@ -105,6 +114,14 @@ public actor ProcessManager {
|
|||||||
children[id] != nil || captured[id] != nil
|
children[id] != nil || captured[id] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Pre-kill hooks
|
||||||
|
|
||||||
|
/// Register a hook to run before `kill(id:)` terminates the child.
|
||||||
|
/// The hook is removed once the child finalizes.
|
||||||
|
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
|
||||||
|
preKillHooks[id] = hook
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Spawn (streaming)
|
// MARK: - Spawn (streaming)
|
||||||
|
|
||||||
/// Spawns a streaming child. Returns after spawn; callers wait for
|
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||||
@@ -119,39 +136,26 @@ public actor ProcessManager {
|
|||||||
) throws {
|
) throws {
|
||||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
let process = Process()
|
let prepared = makeProcess(
|
||||||
let stdinPipe = Pipe()
|
binary: binary,
|
||||||
let stdoutPipe = Pipe()
|
arguments: arguments,
|
||||||
let stderrPipe = Pipe()
|
workingDirectory: workingDirectory,
|
||||||
process.executableURL = binary
|
environment: environment,
|
||||||
process.arguments = arguments
|
includeStdin: true
|
||||||
process.currentDirectoryURL = workingDirectory
|
|
||||||
process.standardInput = stdinPipe
|
|
||||||
process.standardOutput = stdoutPipe
|
|
||||||
process.standardError = stderrPipe
|
|
||||||
process.environment = childEnvironment(extra: environment)
|
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
|
||||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
)
|
||||||
|
let process = prepared.process
|
||||||
|
|
||||||
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||||
|
|
||||||
children[id] = RunningChild(
|
children[id] = RunningChild(
|
||||||
process: process,
|
process: process,
|
||||||
stdin: stdinPipe.fileHandleForWriting,
|
stdin: prepared.stdinPipe?.fileHandleForWriting,
|
||||||
stdoutDecoder: ProcessLineDecoder(),
|
stdoutDecoder: ProcessLineDecoder(),
|
||||||
stderrDecoder: ProcessLineDecoder()
|
stderrDecoder: ProcessLineDecoder()
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
|
||||||
try process.run()
|
let stderrHandle = prepared.stderrPipe.fileHandleForReading
|
||||||
} catch {
|
|
||||||
children.removeValue(forKey: id)
|
|
||||||
emit(.error(id: id, message: error.localizedDescription))
|
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
|
||||||
let stderrHandle = stderrPipe.fileHandleForReading
|
|
||||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -163,9 +167,22 @@ public actor ProcessManager {
|
|||||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||||
}
|
}
|
||||||
|
|
||||||
process.terminationHandler = { [weak self] proc in
|
attachTerminationHandler(process) { [weak self] code in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
startWaitUntilExitWatchdog(process) { [weak self] code in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.didTerminate(id: id, code: code) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +190,8 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
/// Runs a child to completion and returns all output. Reads stdout
|
/// Runs a child to completion and returns all output. Reads stdout
|
||||||
/// and stderr concurrently so a full pipe buffer can never deadlock
|
/// and stderr concurrently so a full pipe buffer can never deadlock
|
||||||
/// the child. Used by `printcal` / `applycal` (docs/03).
|
/// the child. Used by `printcal` / `applycal` (docs/03) and by
|
||||||
|
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
|
||||||
public func runCaptured(
|
public func runCaptured(
|
||||||
id: String,
|
id: String,
|
||||||
binary: URL,
|
binary: URL,
|
||||||
@@ -183,31 +201,93 @@ public actor ProcessManager {
|
|||||||
) async throws -> CapturedResult {
|
) async throws -> CapturedResult {
|
||||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||||
|
|
||||||
let process = Process()
|
let prepared = makeProcess(
|
||||||
let stdoutPipe = Pipe()
|
binary: binary,
|
||||||
let stderrPipe = Pipe()
|
arguments: arguments,
|
||||||
process.executableURL = binary
|
workingDirectory: workingDirectory,
|
||||||
process.arguments = arguments
|
environment: environment,
|
||||||
process.currentDirectoryURL = workingDirectory
|
includeStdin: false
|
||||||
process.standardOutput = stdoutPipe
|
|
||||||
process.standardError = stderrPipe
|
|
||||||
process.environment = childEnvironment(extra: environment)
|
|
||||||
|
|
||||||
AppLogger(category: "process").debug(
|
|
||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
|
||||||
)
|
)
|
||||||
|
let process = prepared.process
|
||||||
|
let stdoutPipe = prepared.stdoutPipe
|
||||||
|
let stderrPipe = prepared.stderrPipe
|
||||||
|
|
||||||
// Register before run() so a concurrent duplicate spawn fails.
|
logSpawn(id: id, binary: binary, arguments: arguments, captured: true)
|
||||||
|
|
||||||
|
// Register and set up the termination hand-off before run() so
|
||||||
|
// a very fast exit is never missed (#50, #52).
|
||||||
captured[id] = process
|
captured[id] = process
|
||||||
|
|
||||||
|
let capturedProcess = process
|
||||||
|
|
||||||
|
// Box is local and synchronised with an NSLock; the @unchecked
|
||||||
|
// Sendable annotation is safe because all access is under the lock.
|
||||||
|
final class Box: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var status: Int32?
|
||||||
|
private var continuation: CheckedContinuation<Int32, Never>?
|
||||||
|
|
||||||
|
/// Try to resume an already-stored continuation with the exit
|
||||||
|
/// status. Returns true if a continuation was resumed.
|
||||||
|
func resume(with status: Int32) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
if let cont = continuation {
|
||||||
|
continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
cont.resume(returning: status)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
self.status = status
|
||||||
|
lock.unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a continuation, returning any status that arrived
|
||||||
|
/// before it. The caller must resume with the returned status.
|
||||||
|
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
|
||||||
|
lock.lock()
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
self.continuation = continuation
|
||||||
|
// A fast exit may have raced past the first nil-check.
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let box = Box()
|
||||||
|
attachTerminationHandler(capturedProcess) { status in
|
||||||
|
_ = box.resume(with: status)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
} catch {
|
} catch {
|
||||||
|
_ = box.resume(with: -1)
|
||||||
captured.removeValue(forKey: id)
|
captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
startWaitUntilExitWatchdog(capturedProcess) { status in
|
||||||
|
_ = box.resume(with: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||||
|
// as soon as the child exits; the child still has its own copies.
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
|
||||||
|
return await withTaskCancellationHandler {
|
||||||
async let outData = Task.detached {
|
async let outData = Task.detached {
|
||||||
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
}.value
|
}.value
|
||||||
@@ -216,22 +296,35 @@ public actor ProcessManager {
|
|||||||
}.value
|
}.value
|
||||||
|
|
||||||
let code = await withCheckedContinuation { continuation in
|
let code = await withCheckedContinuation { continuation in
|
||||||
process.terminationHandler = { proc in
|
if let status = box.store(continuation) {
|
||||||
continuation.resume(returning: proc.terminationStatus)
|
continuation.resume(returning: status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (out, err) = await (outData, errData)
|
let (out, err) = await (outData, errData)
|
||||||
// If kill() already reaped this child, its exit event went out.
|
|
||||||
if captured.removeValue(forKey: id) != nil {
|
// Emit the real exit code once, regardless of whether kill()
|
||||||
|
// already removed the id from `captured`.
|
||||||
|
_ = captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
emit(.exit(id: id, code: code))
|
emit(.exit(id: id, code: code))
|
||||||
}
|
|
||||||
|
|
||||||
return CapturedResult(
|
return CapturedResult(
|
||||||
stdout: String(decoding: out, as: UTF8.self),
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
stderr: String(decoding: err, as: UTF8.self),
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
exitCode: code
|
exitCode: code
|
||||||
)
|
)
|
||||||
|
} onCancel: { [weak self] in
|
||||||
|
// If the awaiting Task is cancelled, terminate the child so
|
||||||
|
// callers like runApplycal never replace a good profile with
|
||||||
|
// a truncated tmp.
|
||||||
|
if capturedProcess.isRunning {
|
||||||
|
capturedProcess.terminate()
|
||||||
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.kill(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - stdin
|
// MARK: - stdin
|
||||||
@@ -254,41 +347,166 @@ public actor ProcessManager {
|
|||||||
try sendStdin(id: id, bytes: Data(text.utf8))
|
try sendStdin(id: id, bytes: Data(text.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Partial-line flush
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail of a streaming child's stdout
|
||||||
|
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
|
||||||
|
/// to flush progress dots without waiting for a newline.
|
||||||
|
public func flushPartialLine(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if let tail = child.stdoutDecoder.flushPartial() {
|
||||||
|
emitStdoutLine(id: id, line: tail)
|
||||||
|
}
|
||||||
|
if let tail = child.stderrDecoder.flushPartial() {
|
||||||
|
emit(.stderr(id: id, line: tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
children[id] = child
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Kill
|
// MARK: - Kill
|
||||||
|
|
||||||
/// Terminates a child. The `exit` event still fires exactly once.
|
/// Terminates a child. First runs any registered pre-kill hook, then
|
||||||
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
/// drops stdin and signals the process. For streaming children the
|
||||||
public func kill(id: String) {
|
/// `exit` event is emitted once both stdout and stderr EOFs have been
|
||||||
|
/// seen (or the watchdog finalizes). For captured children the real
|
||||||
|
/// exit code is emitted by `runCaptured` itself.
|
||||||
|
public func kill(id: String) async {
|
||||||
|
if let hook = preKillHooks.removeValue(forKey: id) {
|
||||||
|
await hook()
|
||||||
|
}
|
||||||
|
|
||||||
if var child = children[id] {
|
if var child = children[id] {
|
||||||
try? child.stdin?.close()
|
try? child.stdin?.close()
|
||||||
child.stdin = nil
|
child.stdin = nil
|
||||||
children[id] = child
|
children[id] = child
|
||||||
|
|
||||||
if child.process.isRunning {
|
if child.process.isRunning {
|
||||||
child.process.terminate()
|
child.process.terminate()
|
||||||
} else {
|
} else if child.pendingExitCode == nil {
|
||||||
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
// The process already exited but `didTerminate` has not
|
||||||
|
// run; synthesize it so `maybeFinalize` can fire.
|
||||||
|
didTerminate(id: id, code: child.process.terminationStatus)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let process = captured[id] {
|
if let process = captured[id] {
|
||||||
if process.isRunning { process.terminate() }
|
if process.isRunning { process.terminate() }
|
||||||
if captured.removeValue(forKey: id) != nil {
|
// Do not emit `.exit` here; `runCaptured` emits the real code
|
||||||
emit(.exit(id: id, code: process.terminationStatus))
|
// after the process reaps.
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminates every running child; returns how many were signaled
|
/// Terminates every running child; returns how many were signaled
|
||||||
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func killAll() -> Int {
|
public func killAll() async -> Int {
|
||||||
let ids = Array(children.keys) + Array(captured.keys)
|
let ids = runningIDs
|
||||||
for id in ids { kill(id: id) }
|
for id in ids { await kill(id: id) }
|
||||||
return ids.count
|
return ids.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Force kill (SIGKILL fallback)
|
||||||
|
|
||||||
|
/// Sends `SIGKILL` to a streaming child if it is still running.
|
||||||
|
/// Used by the finalization watchdog when a graceful `terminate()`
|
||||||
|
/// does not cause the process to exit.
|
||||||
|
public func forceKill(id: String) {
|
||||||
|
guard let child = children[id],
|
||||||
|
!child.finalized,
|
||||||
|
child.process.isRunning
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let pid = child.process.processIdentifier
|
||||||
|
guard pid > 0 else { return }
|
||||||
|
_ = Darwin.kill(pid, SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private struct PreparedProcess {
|
||||||
|
let process: Process
|
||||||
|
let stdinPipe: Pipe?
|
||||||
|
let stdoutPipe: Pipe
|
||||||
|
let stderrPipe: Pipe
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProcess(
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
workingDirectory: URL?,
|
||||||
|
environment: [String: String],
|
||||||
|
includeStdin: Bool
|
||||||
|
) -> PreparedProcess {
|
||||||
|
let process = Process()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
|
||||||
|
process.executableURL = binary
|
||||||
|
process.arguments = arguments
|
||||||
|
process.currentDirectoryURL = workingDirectory
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
if let stdinPipe {
|
||||||
|
process.standardInput = stdinPipe
|
||||||
|
}
|
||||||
|
process.environment = childEnvironment(extra: environment)
|
||||||
|
return PreparedProcess(
|
||||||
|
process: process,
|
||||||
|
stdinPipe: stdinPipe,
|
||||||
|
stdoutPipe: stdoutPipe,
|
||||||
|
stderrPipe: stderrPipe
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func logSpawn(
|
||||||
|
id: String,
|
||||||
|
binary: URL,
|
||||||
|
arguments: [String],
|
||||||
|
captured: Bool
|
||||||
|
) {
|
||||||
|
let prefix = captured ? "spawn(captured)" : "spawn"
|
||||||
|
AppLogger(category: "process").debug(
|
||||||
|
"\(prefix) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `terminationHandler` can lose a fast-exit race on a loaded host;
|
||||||
|
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||||
|
/// The handler is attached before `run()`; the wait thread starts
|
||||||
|
/// only after a successful launch — `terminationStatus` on an
|
||||||
|
/// unlaunched NSTask raises NSInvalidArgumentException.
|
||||||
|
private func attachTerminationHandler(
|
||||||
|
_ process: Process,
|
||||||
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
|
) {
|
||||||
|
process.terminationHandler = { proc in
|
||||||
|
onExit(proc.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startWaitUntilExitWatchdog(
|
||||||
|
_ process: Process,
|
||||||
|
onExit: @escaping @Sendable (Int32) -> Void
|
||||||
|
) {
|
||||||
|
Task.detached { [process] in
|
||||||
|
process.waitUntilExit()
|
||||||
|
onExit(process.terminationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emitStdoutLine(id: String, line: String) {
|
||||||
|
if line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||||
var env = ProcessInfo.processInfo.environment
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||||
@@ -320,15 +538,14 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
let log = AppLogger(category: "subprocess")
|
let log = AppLogger(category: "subprocess")
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
if !isStderr {
|
||||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
emitStdoutLine(id: id, line: line)
|
||||||
emit(.jsonRow(id: id, payload: payload))
|
if !line.hasPrefix(Self.rowColorsPrefix) {
|
||||||
} else if isStderr {
|
log.info("[\(id)] \(line)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
log.warn("[\(id)] \(line)")
|
log.warn("[\(id)] \(line)")
|
||||||
emit(.stderr(id: id, line: line))
|
emit(.stderr(id: id, line: line))
|
||||||
} else {
|
|
||||||
log.info("[\(id)] \(line)")
|
|
||||||
emit(.stdout(id: id, line: line))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +555,16 @@ public actor ProcessManager {
|
|||||||
child.pendingExitCode = code
|
child.pendingExitCode = code
|
||||||
try? child.stdin?.close()
|
try? child.stdin?.close()
|
||||||
child.stdin = nil
|
child.stdin = nil
|
||||||
|
|
||||||
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
|
child.finalizeTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
guard let self else { return }
|
||||||
|
await self.forceKill(id: id)
|
||||||
|
await self.forceFinalize(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
children[id] = child
|
children[id] = child
|
||||||
maybeFinalize(id: id)
|
maybeFinalize(id: id)
|
||||||
}
|
}
|
||||||
@@ -350,17 +577,17 @@ public actor ProcessManager {
|
|||||||
child.stdoutEOF, child.stderrEOF,
|
child.stdoutEOF, child.stderrEOF,
|
||||||
!child.finalized
|
!child.finalized
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
child.finalized = true
|
child.finalized = true
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
children.removeValue(forKey: id)
|
children.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
|
||||||
// Flush unterminated tail lines.
|
// Flush unterminated tail lines.
|
||||||
if var decoder = Optional(child.stdoutDecoder),
|
if var decoder = Optional(child.stdoutDecoder),
|
||||||
let tail = decoder.finish() {
|
let tail = decoder.finish() {
|
||||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
emitStdoutLine(id: id, line: tail)
|
||||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
|
||||||
} else {
|
|
||||||
emit(.stdout(id: id, line: tail))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if var decoder = Optional(child.stderrDecoder),
|
if var decoder = Optional(child.stderrDecoder),
|
||||||
let tail = decoder.finish() {
|
let tail = decoder.finish() {
|
||||||
@@ -368,4 +595,20 @@ public actor ProcessManager {
|
|||||||
}
|
}
|
||||||
emit(.exit(id: id, code: code))
|
emit(.exit(id: id, code: code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forces finalization even when one or both EOFs are missing.
|
||||||
|
/// Used by the `didTerminate` watchdog.
|
||||||
|
private func forceFinalize(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if child.pendingExitCode == nil {
|
||||||
|
child.pendingExitCode = -9
|
||||||
|
}
|
||||||
|
child.stdoutEOF = true
|
||||||
|
child.stderrEOF = true
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `applycal` argv construction.
|
||||||
|
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidCalibrationPath
|
||||||
|
case invalidInputProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCalibrationPath:
|
||||||
|
return "Calibration path is invalid or empty"
|
||||||
|
case .invalidInputProfileURL:
|
||||||
|
return "Input profile path is invalid or empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `applycal` tool.
|
||||||
|
///
|
||||||
|
/// `applycal` is always run captured, never streamed.
|
||||||
|
public enum ApplycalArgs {
|
||||||
|
|
||||||
|
/// Builds `applycal -v -a {cal} {input} [{output}]`.
|
||||||
|
///
|
||||||
|
/// `-u` (unapply) is rejected at the builder level — the UI never
|
||||||
|
/// sends it (docs/04 §7.2).
|
||||||
|
public static func build(config: ApplycalConfig) throws -> [String] {
|
||||||
|
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
|
||||||
|
|
||||||
|
let input = config.inputProfileURL.path
|
||||||
|
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
if config.unapply {
|
||||||
|
// Defensive: should never be called from the UI.
|
||||||
|
args.append("-u")
|
||||||
|
} else {
|
||||||
|
args.append("-a")
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(contentsOf: [cal, input])
|
||||||
|
|
||||||
|
if let output = config.outputProfileURL?.path, !output.isEmpty {
|
||||||
|
args.append(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `applycal` run.
|
||||||
|
public struct ApplycalConfig: Sendable, Equatable {
|
||||||
|
public var calibrationPath: String
|
||||||
|
public var inputProfileURL: URL
|
||||||
|
public var outputProfileURL: URL?
|
||||||
|
public var unapply: Bool
|
||||||
|
|
||||||
|
/// In-place when `outputProfileURL` is `nil`.
|
||||||
|
public init(
|
||||||
|
calibrationPath: String,
|
||||||
|
inputProfileURL: URL,
|
||||||
|
outputProfileURL: URL? = nil,
|
||||||
|
unapply: Bool = false
|
||||||
|
) {
|
||||||
|
self.calibrationPath = calibrationPath
|
||||||
|
self.inputProfileURL = inputProfileURL
|
||||||
|
self.outputProfileURL = outputProfileURL
|
||||||
|
self.unapply = unapply
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
|
||||||
|
///
|
||||||
|
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
|
||||||
|
/// and the runner all derive identity from this type. Do not add a second
|
||||||
|
/// `hasPrefix("CAL_")` ternary elsewhere.
|
||||||
|
public struct CalibrationIdentity: Equatable, Sendable {
|
||||||
|
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
|
||||||
|
public var originalBasename: String
|
||||||
|
/// Always `CAL_{original}` when original is non-empty.
|
||||||
|
public var calibrationBasename: String
|
||||||
|
|
||||||
|
public init(originalBasename: String, calibrationBasename: String) {
|
||||||
|
self.originalBasename = originalBasename
|
||||||
|
self.calibrationBasename = calibrationBasename
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isCalibration(_ basename: String) -> Bool {
|
||||||
|
basename.hasPrefix("CAL_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only place that adds a `CAL_` prefix.
|
||||||
|
public static func prefix(_ original: String) -> String {
|
||||||
|
if original.isEmpty { return original }
|
||||||
|
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a single leading `CAL_` if present.
|
||||||
|
public static func strip(_ basename: String) -> String {
|
||||||
|
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive identity from the live wizard basename and the persisted
|
||||||
|
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||||
|
/// name (Force Quit mid-calibration). An empty live basename always
|
||||||
|
/// produces an empty identity — a persisted original must never
|
||||||
|
/// resurrect a target that no longer exists (#83).
|
||||||
|
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||||
|
guard !liveBasename.isEmpty else {
|
||||||
|
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||||
|
}
|
||||||
|
let original: String
|
||||||
|
if liveBasename.hasPrefix("CAL_") {
|
||||||
|
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||||
|
} else {
|
||||||
|
original = liveBasename
|
||||||
|
}
|
||||||
|
return CalibrationIdentity(
|
||||||
|
originalBasename: original,
|
||||||
|
calibrationBasename: prefix(original)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single channel's calibration curve.
|
||||||
|
public struct CalibrationCurve: Sendable, Equatable {
|
||||||
|
public let channel: Character
|
||||||
|
public let input: [Double]
|
||||||
|
public let output: [Double]
|
||||||
|
|
||||||
|
public init(channel: Character, input: [Double], output: [Double]) {
|
||||||
|
self.channel = channel
|
||||||
|
self.input = input
|
||||||
|
self.output = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Argyll `.cal` curve data.
|
||||||
|
public struct CalibrationData: Sendable, Equatable {
|
||||||
|
public var colorRep: String
|
||||||
|
public var descriptor: String?
|
||||||
|
public var created: Date?
|
||||||
|
public var maxTac: Double?
|
||||||
|
public var inkLimits: [Character: Double]
|
||||||
|
public var curves: [CalibrationCurve]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
colorRep: String = "",
|
||||||
|
descriptor: String? = nil,
|
||||||
|
created: Date? = nil,
|
||||||
|
maxTac: Double? = nil,
|
||||||
|
inkLimits: [Character: Double] = [:],
|
||||||
|
curves: [CalibrationCurve] = []
|
||||||
|
) {
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.descriptor = descriptor
|
||||||
|
self.created = created
|
||||||
|
self.maxTac = maxTac
|
||||||
|
self.inkLimits = inkLimits
|
||||||
|
self.curves = curves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from loading and parsing a `.cal` file.
|
||||||
|
public enum CalibrationStoreError: Error, Equatable {
|
||||||
|
case unreadableFile
|
||||||
|
case missingColorRep
|
||||||
|
case missingCurveData
|
||||||
|
case unsupportedFormat
|
||||||
|
case parseFailed(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unreadableFile:
|
||||||
|
return "Could not read the calibration file."
|
||||||
|
case .missingColorRep:
|
||||||
|
return "The .cal file is missing its COLOR_REP header."
|
||||||
|
case .missingCurveData:
|
||||||
|
return "The .cal file contains no calibration curve data."
|
||||||
|
case .unsupportedFormat:
|
||||||
|
return "The .cal file format is not supported."
|
||||||
|
case .parseFailed(let reason):
|
||||||
|
return "Calibration parse failed: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store for a calibration curve, its metadata, and staleness checks.
|
||||||
|
public actor CalibrationStore {
|
||||||
|
|
||||||
|
public private(set) var data: CalibrationData?
|
||||||
|
public private(set) var sourceURL: URL?
|
||||||
|
public private(set) var storedPrinterName: String?
|
||||||
|
|
||||||
|
/// Number of days after which a calibration is considered stale.
|
||||||
|
public var staleDays: Int
|
||||||
|
|
||||||
|
public init(staleDays: Int = 30) {
|
||||||
|
self.staleDays = staleDays
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and parse a `.cal` file.
|
||||||
|
public func load(url: URL) async throws {
|
||||||
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
|
|
||||||
|
guard let colorRep = dataset.colorRep, !colorRep.isEmpty else {
|
||||||
|
throw CalibrationStoreError.missingColorRep
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = CalibrationData()
|
||||||
|
data.colorRep = colorRep
|
||||||
|
data.descriptor = dataset.keywords["DESCRIPTOR"]
|
||||||
|
|
||||||
|
if let createdString = dataset.keywords["CREATED"] {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
data.created = formatter.date(from: createdString)
|
||||||
|
?? Date(timeIntervalSince1970: 0)
|
||||||
|
} else {
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
data.created = attrs?[.modificationDate] as? Date
|
||||||
|
}
|
||||||
|
|
||||||
|
let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"]
|
||||||
|
for key in limitKeys {
|
||||||
|
if let raw = dataset.keywords[key], let value = Double(raw) {
|
||||||
|
data.maxTac = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") {
|
||||||
|
let suffix = key.dropFirst("INK_LIMIT_".count)
|
||||||
|
guard let channel = suffix.first, let value = Double(raw) else { continue }
|
||||||
|
data.inkLimits[channel] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
data.curves = try Self.extractCurves(from: dataset)
|
||||||
|
guard !data.curves.isEmpty else {
|
||||||
|
throw CalibrationStoreError.missingCurveData
|
||||||
|
}
|
||||||
|
|
||||||
|
self.data = data
|
||||||
|
self.sourceURL = url
|
||||||
|
|
||||||
|
// Printer name may live in a sidecar JSON. For now, fall back to the
|
||||||
|
// descriptor so callers have something to compare.
|
||||||
|
self.storedPrinterName = data.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an explicit printer name (e.g. from a sidecar).
|
||||||
|
public func setPrinterName(_ name: String?) {
|
||||||
|
self.storedPrinterName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the loaded calibration is older than `staleDays` or the
|
||||||
|
/// printer name does not match.
|
||||||
|
public func isStale(comparedTo currentPrinter: String? = nil) -> Bool {
|
||||||
|
guard let data else { return true }
|
||||||
|
|
||||||
|
if let created = data.created,
|
||||||
|
let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created),
|
||||||
|
Date() > threshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stored = storedPrinterName, !stored.isEmpty,
|
||||||
|
let current = currentPrinter, !current.isEmpty,
|
||||||
|
stored != current {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] {
|
||||||
|
// Argyll .cal files contain an INPUT_VALUE column and one or more
|
||||||
|
// per-channel output columns. Field names vary by COLOR_REP.
|
||||||
|
let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" }
|
||||||
|
guard !outputFields.isEmpty else {
|
||||||
|
// Older .cal files may only have one output column named OUTPUT_VALUE.
|
||||||
|
if dataset.fieldNames.contains("OUTPUT_VALUE") {
|
||||||
|
return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)]
|
||||||
|
}
|
||||||
|
throw CalibrationStoreError.missingCurveData
|
||||||
|
}
|
||||||
|
|
||||||
|
var curves = [CalibrationCurve]()
|
||||||
|
for field in outputFields {
|
||||||
|
let channel = field.first ?? "?"
|
||||||
|
let curve = try buildCurve(channel: channel, field: field, dataset: dataset)
|
||||||
|
curves.append(curve)
|
||||||
|
}
|
||||||
|
return curves
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildCurve(
|
||||||
|
channel: Character,
|
||||||
|
field: String,
|
||||||
|
dataset: CGATSDataset
|
||||||
|
) throws -> CalibrationCurve {
|
||||||
|
var input = [Double]()
|
||||||
|
var output = [Double]()
|
||||||
|
|
||||||
|
for sample in dataset.samples {
|
||||||
|
guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field],
|
||||||
|
let inVal = parseNumber(inRaw),
|
||||||
|
let outRaw = sample.values[field],
|
||||||
|
let outVal = parseNumber(outRaw) else {
|
||||||
|
throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)")
|
||||||
|
}
|
||||||
|
input.append(inVal)
|
||||||
|
output.append(outVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CalibrationCurve(channel: channel, input: input, output: output)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during calibration `targen` argv construction.
|
||||||
|
public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidSteps(Int)
|
||||||
|
case invalidInkLimit(Int)
|
||||||
|
case invalidWhitePatches(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid calibration basename: \(name)"
|
||||||
|
case .invalidSteps(let steps):
|
||||||
|
return "Calibration steps must be 11–51, got: \(steps)"
|
||||||
|
case .invalidInkLimit(let limit):
|
||||||
|
return "Calibration ink limit must be 200–400, got: \(limit)"
|
||||||
|
case .invalidWhitePatches(let count):
|
||||||
|
return "Calibration white patches cannot be negative, got: \(count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a calibration wedge `targen` run.
|
||||||
|
public struct CalibrationTargenConfig: Sendable, Equatable {
|
||||||
|
public var colourSpace: ColourSpace
|
||||||
|
public var steps: Int
|
||||||
|
public var whitePatches: Int
|
||||||
|
public var includeNeutralEmphasis: Bool
|
||||||
|
public var inkLimit: Int?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
colourSpace: ColourSpace = .rgb,
|
||||||
|
steps: Int = 21,
|
||||||
|
whitePatches: Int = 4,
|
||||||
|
includeNeutralEmphasis: Bool = false,
|
||||||
|
inkLimit: Int? = nil,
|
||||||
|
basename: String = "",
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.steps = steps
|
||||||
|
self.whitePatches = whitePatches
|
||||||
|
self.includeNeutralEmphasis = includeNeutralEmphasis
|
||||||
|
self.inkLimit = inkLimit
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for the Stage 0 calibration `targen` chart.
|
||||||
|
///
|
||||||
|
/// Produces a per-channel wedge with `-f 0` (no full-spread patches).
|
||||||
|
public enum CalibrationTargenArgs {
|
||||||
|
|
||||||
|
/// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`.
|
||||||
|
public static func build(config: CalibrationTargenConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
guard (11...51).contains(config.steps) else {
|
||||||
|
throw CalibrationTargenArgError.invalidSteps(config.steps)
|
||||||
|
}
|
||||||
|
guard config.whitePatches >= 0 else {
|
||||||
|
throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
var args: [String] = [
|
||||||
|
"-v",
|
||||||
|
"-d", config.colourSpace.dFlagValue,
|
||||||
|
"-s", "\(config.steps)",
|
||||||
|
"-g", "\(config.steps)",
|
||||||
|
"-e", "\(config.whitePatches)",
|
||||||
|
"-f", "0"
|
||||||
|
]
|
||||||
|
|
||||||
|
if config.includeNeutralEmphasis {
|
||||||
|
args.append(contentsOf: ["-n", "\(config.steps)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.colourSpace == .cmyk, let inkLimit = config.inkLimit {
|
||||||
|
guard (200...400).contains(inkLimit) else {
|
||||||
|
throw CalibrationTargenArgError.invalidInkLimit(inkLimit)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||||
|
args.append(calBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `colprof` argv construction.
|
||||||
|
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid colprof basename: \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `colprof` tool.
|
||||||
|
public enum ColprofArgs {
|
||||||
|
|
||||||
|
/// Builds `colprof` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
|
||||||
|
/// only when their fields are non-empty and meaningful. `-f` has
|
||||||
|
/// special handling for "none" (omit), "" (bare flag), and a custom
|
||||||
|
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
|
||||||
|
/// skipped when set to "none".
|
||||||
|
public static func build(config: ColprofConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
args.append(contentsOf: ["-a", config.algorithm])
|
||||||
|
args.append(contentsOf: ["-q", config.quality])
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
|
||||||
|
|
||||||
|
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||||
|
switch fwa.lowercased() {
|
||||||
|
case "none", "":
|
||||||
|
if fwa.isEmpty {
|
||||||
|
args.append("-f")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
args.append(contentsOf: ["-f", fwa])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
|
||||||
|
|
||||||
|
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-c", inputCond])
|
||||||
|
}
|
||||||
|
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-d", outputCond])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
|
||||||
|
public struct ColprofConfig: Sendable, Equatable {
|
||||||
|
public var algorithm: String
|
||||||
|
public var quality: String
|
||||||
|
public var intent: String?
|
||||||
|
public var fwa: String?
|
||||||
|
public var illuminant: String?
|
||||||
|
public var observer: String?
|
||||||
|
public var inputViewingCond: String?
|
||||||
|
public var outputViewingCond: String?
|
||||||
|
public var description: String?
|
||||||
|
public var copyright: String?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
algorithm: String = "l",
|
||||||
|
quality: String = "m",
|
||||||
|
intent: String? = nil,
|
||||||
|
fwa: String? = nil,
|
||||||
|
illuminant: String? = nil,
|
||||||
|
observer: String? = nil,
|
||||||
|
inputViewingCond: String? = nil,
|
||||||
|
outputViewingCond: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
copyright: String? = nil,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.quality = quality
|
||||||
|
self.intent = intent
|
||||||
|
self.fwa = fwa
|
||||||
|
self.illuminant = illuminant
|
||||||
|
self.observer = observer
|
||||||
|
self.inputViewingCond = inputViewingCond
|
||||||
|
self.outputViewingCond = outputViewingCond
|
||||||
|
self.description = description
|
||||||
|
self.copyright = copyright
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Classified `colprof` stdout progress milestone.
|
||||||
|
public enum ColprofProgress: Sendable, Equatable {
|
||||||
|
case gamutMapping
|
||||||
|
case fittingClut
|
||||||
|
case writingIcc
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `colprof` plaintext progress (docs/16 §6.3).
|
||||||
|
///
|
||||||
|
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
|
||||||
|
/// Progress is therefore inferred from case-insensitive substring matches.
|
||||||
|
public enum ColprofProgressClassifier {
|
||||||
|
|
||||||
|
public static func classify(line: String) -> ColprofProgress {
|
||||||
|
let lower = line.lowercased()
|
||||||
|
if lower.contains("gamut mapping") {
|
||||||
|
return .gamutMapping
|
||||||
|
}
|
||||||
|
if lower.contains("fitting") || lower.contains("clut") {
|
||||||
|
return .fittingClut
|
||||||
|
}
|
||||||
|
if lower.contains("writing") || lower.contains("icc profile") {
|
||||||
|
return .writingIcc
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Computes a consecutive-breach warning from verification history.
|
||||||
|
///
|
||||||
|
/// A drift alert triggers when the most recent chronologically consecutive
|
||||||
|
/// poor records form a run of at least two, and the first and last of that
|
||||||
|
/// run are on distinct UTC days or at least one hour apart.
|
||||||
|
public enum DriftAlert {
|
||||||
|
|
||||||
|
/// Returns an alert message, or `nil` when no consecutive breach exists.
|
||||||
|
public static func compute(from records: [VerificationRecord]) -> String? {
|
||||||
|
// Work in chronological order.
|
||||||
|
let chronological = records.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
|
||||||
|
// Build the longest suffix of consecutive `.poor` records.
|
||||||
|
// Non-poor records break the run, so we stop at the first non-poor
|
||||||
|
// encountered from the end.
|
||||||
|
var run: [VerificationRecord] = []
|
||||||
|
for record in chronological.reversed() {
|
||||||
|
if record.status == .poor {
|
||||||
|
run.insert(record, at: 0)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard run.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let first = run.first!
|
||||||
|
let last = run.last!
|
||||||
|
|
||||||
|
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
|
||||||
|
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
|
||||||
|
|
||||||
|
if !sameDay || oneHour {
|
||||||
|
return "Drift alert: poor results between \(first.id) and \(last.id)."
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Calendar {
|
||||||
|
static let utc: Calendar = {
|
||||||
|
var c = Calendar(identifier: .iso8601)
|
||||||
|
c.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `iccgamut` argv construction.
|
||||||
|
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidProfileURL:
|
||||||
|
return "iccgamut requires a valid profile path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `iccgamut` tool.
|
||||||
|
public enum IccgamutArgs {
|
||||||
|
|
||||||
|
/// Builds `iccgamut -v -d {density} {profilePath}`.
|
||||||
|
///
|
||||||
|
/// The caller is responsible for ensuring `density` is a positive
|
||||||
|
/// integer. `-d` here is surface **density**, not a directory.
|
||||||
|
public static func build(config: IccgamutConfig) throws -> [String] {
|
||||||
|
let path = config.profileURL.path
|
||||||
|
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
|
||||||
|
|
||||||
|
let density = max(1, config.density)
|
||||||
|
return ["-v", "-d", "\(density)", path]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `iccgamut` run.
|
||||||
|
public struct IccgamutConfig: Sendable, Equatable {
|
||||||
|
public var profileURL: URL
|
||||||
|
public var density: Int
|
||||||
|
|
||||||
|
public init(profileURL: URL, density: Int = 10) {
|
||||||
|
self.profileURL = profileURL
|
||||||
|
self.density = density
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a profile installation.
|
||||||
|
public struct InstallProfileConfig: Sendable, Equatable {
|
||||||
|
public var sourceURL: URL
|
||||||
|
public var options: InstallProfileOptions
|
||||||
|
|
||||||
|
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Collision policy for profile installation.
|
||||||
|
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case overwrite
|
||||||
|
case rename
|
||||||
|
case cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for installing a finished profile into the OS colour store.
|
||||||
|
public struct InstallProfileOptions: Sendable, Equatable, Codable {
|
||||||
|
public var forceOverwrite: Bool
|
||||||
|
public var preferSystem: Bool
|
||||||
|
public var collisionPolicy: ProfileCollisionPolicy
|
||||||
|
public var openColorPanel: Bool
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
forceOverwrite: Bool = false,
|
||||||
|
preferSystem: Bool = false,
|
||||||
|
collisionPolicy: ProfileCollisionPolicy = .cancel,
|
||||||
|
openColorPanel: Bool = false,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.forceOverwrite = forceOverwrite
|
||||||
|
self.preferSystem = preferSystem
|
||||||
|
self.collisionPolicy = collisionPolicy
|
||||||
|
self.openColorPanel = openColorPanel
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of installing a profile into the OS colour store.
|
||||||
|
public struct InstallProfileResult: Sendable, Equatable, Codable {
|
||||||
|
public var destPath: String
|
||||||
|
public var registered: Bool
|
||||||
|
public var overwritten: Bool
|
||||||
|
public var renamed: Bool
|
||||||
|
public var openedPanel: Bool
|
||||||
|
public var message: String
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
destPath: String,
|
||||||
|
registered: Bool,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool,
|
||||||
|
openedPanel: Bool,
|
||||||
|
message: String,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.destPath = destPath
|
||||||
|
self.registered = registered
|
||||||
|
self.overwritten = overwritten
|
||||||
|
self.renamed = renamed
|
||||||
|
self.openedPanel = openedPanel
|
||||||
|
self.message = message
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `printcal` argv construction.
|
||||||
|
public enum PrintcalArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidTotalInkLimit(Double)
|
||||||
|
case invalidPerChannelLimit(Character, Double)
|
||||||
|
case invalidOutputPath
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid calibration basename: \(name)"
|
||||||
|
case .invalidTotalInkLimit(let limit):
|
||||||
|
return "Total ink limit must be positive, got: \(limit)"
|
||||||
|
case .invalidPerChannelLimit(let channel, let limit):
|
||||||
|
return "\(channel) channel limit must be 0–100, got: \(limit)"
|
||||||
|
case .invalidOutputPath:
|
||||||
|
return "Invalid .cal output path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`.
|
||||||
|
public struct PrintcalChannelLimit: Sendable, Equatable {
|
||||||
|
public let channel: Character
|
||||||
|
public let percent: Double
|
||||||
|
|
||||||
|
public init(channel: Character, percent: Double) {
|
||||||
|
self.channel = channel
|
||||||
|
self.percent = percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `printcal` run.
|
||||||
|
public struct PrintcalConfig: Sendable, Equatable {
|
||||||
|
public var ti3Basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
public var outputURL: URL
|
||||||
|
public var noInkLimit: Bool
|
||||||
|
public var verify: Bool
|
||||||
|
public var previousCalPath: String?
|
||||||
|
public var totalInkLimit: Double?
|
||||||
|
public var channelLimits: [PrintcalChannelLimit]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ti3Basename: String,
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
outputURL: URL,
|
||||||
|
noInkLimit: Bool = false,
|
||||||
|
verify: Bool = false,
|
||||||
|
previousCalPath: String? = nil,
|
||||||
|
totalInkLimit: Double? = nil,
|
||||||
|
channelLimits: [PrintcalChannelLimit] = []
|
||||||
|
) {
|
||||||
|
self.ti3Basename = ti3Basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.outputURL = outputURL
|
||||||
|
self.noInkLimit = noInkLimit
|
||||||
|
self.verify = verify
|
||||||
|
self.previousCalPath = previousCalPath
|
||||||
|
self.totalInkLimit = totalInkLimit
|
||||||
|
self.channelLimits = channelLimits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `printcal` tool.
|
||||||
|
///
|
||||||
|
/// `printcal` is captured, not streamed. JS never sends `-u` (unapply).
|
||||||
|
public enum PrintcalArgs {
|
||||||
|
|
||||||
|
/// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC]
|
||||||
|
/// [-xC pct]... -o out.cal CAL_basename`.
|
||||||
|
public static func build(config: PrintcalConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename)
|
||||||
|
guard !cleanBasename.isEmpty else {
|
||||||
|
throw PrintcalArgError.invalidBasename(config.ti3Basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
var args: [String] = ["-v", "-e"]
|
||||||
|
|
||||||
|
args.append(contentsOf: ArgsBuilder.flag("-I", when: config.noInkLimit))
|
||||||
|
args.append(contentsOf: ArgsBuilder.flag("-z", when: config.verify))
|
||||||
|
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-a", config.previousCalPath))
|
||||||
|
if let tac = config.totalInkLimit, tac > 0 {
|
||||||
|
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
|
||||||
|
} else if let tac = config.totalInkLimit {
|
||||||
|
throw PrintcalArgError.invalidTotalInkLimit(tac)
|
||||||
|
}
|
||||||
|
|
||||||
|
for limit in config.channelLimits {
|
||||||
|
guard (0...100).contains(limit.percent) else {
|
||||||
|
throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !config.outputURL.path.isEmpty else {
|
||||||
|
throw PrintcalArgError.invalidOutputPath
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-o", config.outputURL.path])
|
||||||
|
|
||||||
|
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||||
|
args.append(calBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `profcheck` argv construction.
|
||||||
|
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case missingTi3
|
||||||
|
case missingIcc
|
||||||
|
case invalidTi3Path
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingTi3:
|
||||||
|
return "profcheck requires a .ti3 file"
|
||||||
|
case .missingIcc:
|
||||||
|
return "profcheck requires a profile (.icc/.icm)"
|
||||||
|
case .invalidTi3Path:
|
||||||
|
return "profcheck .ti3 path is invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `profcheck` tool.
|
||||||
|
public enum ProfcheckArgs {
|
||||||
|
|
||||||
|
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
|
||||||
|
///
|
||||||
|
/// The `-u` here is the Gronod fork JSON report flag, not the
|
||||||
|
/// generic `-u` auto-fix that some Argyll builds use.
|
||||||
|
public static func build(config: ProfcheckConfig) throws -> [String] {
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
let iccPath = config.iccURL.path
|
||||||
|
|
||||||
|
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
|
||||||
|
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
|
||||||
|
|
||||||
|
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `profcheck` run.
|
||||||
|
public struct ProfcheckConfig: Sendable, Equatable {
|
||||||
|
public var ti3URL: URL
|
||||||
|
public var iccURL: URL
|
||||||
|
|
||||||
|
public init(ti3URL: URL, iccURL: URL) {
|
||||||
|
self.ti3URL = ti3URL
|
||||||
|
self.iccURL = iccURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors returned when `profcheck` output cannot be parsed.
|
||||||
|
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unparseable
|
||||||
|
case jsonDecodingFailed
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unparseable:
|
||||||
|
return "Could not parse profcheck report"
|
||||||
|
case .jsonDecodingFailed:
|
||||||
|
return "profcheck JSON report could not be decoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
|
||||||
|
public enum ProfcheckParser {
|
||||||
|
|
||||||
|
/// Parsing order (issue #25):
|
||||||
|
/// 1. Patch count from `No of test patches = N`.
|
||||||
|
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
|
||||||
|
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
|
||||||
|
/// 4. Broad regex fallback.
|
||||||
|
/// 5. If no metrics found, return a report whose `warning` is set.
|
||||||
|
public static func parse(_ output: String) -> ProfcheckReport {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
// 1. Patch count.
|
||||||
|
let patchRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"No of test patches\s*=\s*(\d+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = patchRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
), let range = Range(match.range(at: 1), in: output) {
|
||||||
|
let count = Int(output[range])
|
||||||
|
report.patchCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JSON objects.
|
||||||
|
let jsonObjects = extractJSONObjects(from: output)
|
||||||
|
for object in jsonObjects {
|
||||||
|
if let event = object["event"] as? String, event == "report" {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasMetricKeys(object) {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Legacy text.
|
||||||
|
let textRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = textRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
) {
|
||||||
|
let numbers = (1...3).compactMap { i -> Double? in
|
||||||
|
guard let range = Range(match.range(at: i), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
if numbers.count == 3 {
|
||||||
|
report.maxDE = numbers[0]
|
||||||
|
report.avgDE = numbers[1]
|
||||||
|
report.rmsDE = numbers[2]
|
||||||
|
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Broad regex fallback.
|
||||||
|
if let fallback = parseRegexFallback(output) {
|
||||||
|
var merged = fallback
|
||||||
|
merged.patchCount = report.patchCount
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unparseable.
|
||||||
|
report.warning = "profcheck output did not contain a recognisable report."
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON extraction
|
||||||
|
|
||||||
|
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
|
||||||
|
var objects: [[String: Any]] = []
|
||||||
|
var start: String.Index?
|
||||||
|
var depth = 0
|
||||||
|
|
||||||
|
for index in output.indices {
|
||||||
|
let char = output[index]
|
||||||
|
if char == "{" {
|
||||||
|
if depth == 0 {
|
||||||
|
start = index
|
||||||
|
}
|
||||||
|
depth += 1
|
||||||
|
} else if char == "}" {
|
||||||
|
if depth > 0 {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0, let start = start {
|
||||||
|
let jsonString = String(output[start...index])
|
||||||
|
if let data = jsonString.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
objects.append(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
|
||||||
|
let keys = [
|
||||||
|
"avg_de", "avg_de2000",
|
||||||
|
"peak_de", "peak_de2000", "max_de",
|
||||||
|
"rms", "rms_de"
|
||||||
|
]
|
||||||
|
return keys.contains { object[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metrics {
|
||||||
|
var avg: Double?
|
||||||
|
var max: Double?
|
||||||
|
var rms: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func metrics(from object: [String: Any]) -> Metrics? {
|
||||||
|
var m = Metrics()
|
||||||
|
m.avg = doubleValue(for: "avg_de2000", in: object)
|
||||||
|
?? doubleValue(for: "avg_de", in: object)
|
||||||
|
m.max = doubleValue(for: "peak_de2000", in: object)
|
||||||
|
?? doubleValue(for: "peak_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de2000", in: object)
|
||||||
|
m.rms = doubleValue(for: "rms", in: object)
|
||||||
|
?? doubleValue(for: "rms_de", in: object)
|
||||||
|
?? doubleValue(for: "rms_de2000", in: object)
|
||||||
|
|
||||||
|
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
|
||||||
|
if let number = object[key] as? Double { return number }
|
||||||
|
if let number = object[key] as? NSNumber { return number.doubleValue }
|
||||||
|
if let string = object[key] as? String { return Double(string) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
|
||||||
|
report.avgDE = metrics.avg
|
||||||
|
report.maxDE = metrics.max
|
||||||
|
report.rmsDE = metrics.rms
|
||||||
|
if let avg = metrics.avg {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Regex fallback
|
||||||
|
|
||||||
|
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
let avgRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let maxRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let rmsRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
|
||||||
|
report.avgDE = firstDouble(from: output, regex: avgRegex)
|
||||||
|
report.maxDE = firstDouble(from: output, regex: maxRegex)
|
||||||
|
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
|
||||||
|
|
||||||
|
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let avg = report.avgDE {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
|
||||||
|
guard let regex = regex,
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
),
|
||||||
|
let range = Range(match.range(at: 1), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed result from a `profcheck -u` run.
|
||||||
|
public struct ProfcheckReport: Sendable, Equatable, Codable {
|
||||||
|
public var patchCount: Int?
|
||||||
|
public var avgDE: Double?
|
||||||
|
public var maxDE: Double?
|
||||||
|
public var rmsDE: Double?
|
||||||
|
public var status: VerificationStatus?
|
||||||
|
public var warning: String?
|
||||||
|
|
||||||
|
public var isValid: Bool {
|
||||||
|
avgDE != nil && maxDE != nil && rmsDE != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
patchCount: Int? = nil,
|
||||||
|
avgDE: Double? = nil,
|
||||||
|
maxDE: Double? = nil,
|
||||||
|
rmsDE: Double? = nil,
|
||||||
|
status: VerificationStatus? = nil,
|
||||||
|
warning: String? = nil
|
||||||
|
) {
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.status = status
|
||||||
|
self.warning = warning
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown by `ProfileInstaller`.
|
||||||
|
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unsafeStem(String)
|
||||||
|
case sourceMissing
|
||||||
|
case sourceNotProfile
|
||||||
|
case sourceTooSmall
|
||||||
|
case systemRequiresAdminRights
|
||||||
|
case copyFailed(String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsafeStem(let stem):
|
||||||
|
return "Profile name contains unsafe characters: \(stem)"
|
||||||
|
case .sourceMissing:
|
||||||
|
return "Source profile does not exist"
|
||||||
|
case .sourceNotProfile:
|
||||||
|
return "Source must be a .icc or .icm file"
|
||||||
|
case .sourceTooSmall:
|
||||||
|
return "Source file is too small to be a valid profile"
|
||||||
|
case .systemRequiresAdminRights:
|
||||||
|
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
|
||||||
|
case .copyFailed(let reason):
|
||||||
|
return "Could not install profile: \(reason)"
|
||||||
|
case .cancelled:
|
||||||
|
return "Install cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs an ICC/ICM profile into the OS colour store.
|
||||||
|
public enum ProfileInstaller {
|
||||||
|
|
||||||
|
/// Resolves the destination URL that `install` would write to for the
|
||||||
|
/// given source and options, without copying anything. Useful for
|
||||||
|
/// collision previews in the UI.
|
||||||
|
public static func resolveDestinationURL(
|
||||||
|
for config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
|
||||||
|
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
|
||||||
|
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
|
||||||
|
public static func install(
|
||||||
|
config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
|
||||||
|
// Source validation.
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
guard fm.fileExists(atPath: sourceURL.path) else {
|
||||||
|
throw ProfileInstallError.sourceMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
// Destination directory.
|
||||||
|
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
|
||||||
|
try? fm.createDirectory(
|
||||||
|
at: destURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collision resolution.
|
||||||
|
let destExists = fm.fileExists(atPath: destURL.path)
|
||||||
|
if destExists {
|
||||||
|
if config.options.forceOverwrite {
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: true,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .rename {
|
||||||
|
let epoch = Int(Date().timeIntervalSince1970)
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
let renamedURL = destURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: renamedURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: true
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .cancel {
|
||||||
|
throw ProfileInstallError.cancelled
|
||||||
|
} else {
|
||||||
|
// Default with askBeforeOverwrite — the app must decide.
|
||||||
|
throw ProfileInstallError.copyFailed("destination already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private static func validateSourceURL(_ sourceURL: URL) throws {
|
||||||
|
let path = sourceURL.path
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
|
||||||
|
// Reject backslashes anywhere in the path.
|
||||||
|
guard !path.contains("\\") else {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject any path component that is literally "." or "..".
|
||||||
|
// This allows names like "foo..bar" while blocking real traversal.
|
||||||
|
for component in sourceURL.pathComponents {
|
||||||
|
if component == "." || component == ".." {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func destinationDirectory(
|
||||||
|
for options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> URL {
|
||||||
|
if options.preferSystem {
|
||||||
|
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
|
||||||
|
} else {
|
||||||
|
return fileManager.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/ColorSync/Profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func performInstall(
|
||||||
|
from sourceURL: URL,
|
||||||
|
to destURL: URL,
|
||||||
|
options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
|
||||||
|
|
||||||
|
// Remove stale tmp.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fm.copyItem(at: sourceURL, to: tmpURL)
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let tmpSize = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard tmpSize >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: destURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: destURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
// Surface a clear admin-rights hint when writing to system.
|
||||||
|
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
|
||||||
|
throw ProfileInstallError.systemRequiresAdminRights
|
||||||
|
}
|
||||||
|
|
||||||
|
if let installError = error as? ProfileInstallError {
|
||||||
|
throw installError
|
||||||
|
}
|
||||||
|
throw ProfileInstallError.copyFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = fm.fileExists(atPath: destURL.path)
|
||||||
|
|
||||||
|
var openedPanel = false
|
||||||
|
if options.openColorPanel {
|
||||||
|
openedPanel = openColorSyncUtility()
|
||||||
|
}
|
||||||
|
|
||||||
|
return InstallProfileResult(
|
||||||
|
destPath: destURL.path,
|
||||||
|
registered: registered,
|
||||||
|
overwritten: overwritten,
|
||||||
|
renamed: renamed,
|
||||||
|
openedPanel: openedPanel,
|
||||||
|
message: "Profile installed to \(destURL.path)",
|
||||||
|
calibrationNote: options.calibrationNote
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func openColorSyncUtility() -> Bool {
|
||||||
|
let task = Process()
|
||||||
|
task.launchPath = "/usr/bin/open"
|
||||||
|
task.arguments = ["-a", "ColorSync Utility"]
|
||||||
|
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
|
||||||
|
do {
|
||||||
|
try task.run()
|
||||||
|
task.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `VerificationRecord` entries.
|
||||||
|
public actor VerificationHistoryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 1000
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var records: [VerificationRecord] = []
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let fileStore: JSONFileStore<[VerificationRecord]>
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
self.fileStore = JSONFileStore(
|
||||||
|
fileURL: url,
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { [] },
|
||||||
|
dateEncoding: .iso8601,
|
||||||
|
dateDecoding: .iso8601
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads records 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 -> [VerificationRecord] {
|
||||||
|
guard records.isEmpty else { return records }
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||||
|
records = try fileStore.load()
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all records.
|
||||||
|
public func all() -> [VerificationRecord] {
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records matching the optional printer filter.
|
||||||
|
public func filtered(by printer: String?) -> [VerificationRecord] {
|
||||||
|
guard let printer = printer, !printer.isEmpty else { return records }
|
||||||
|
return records.filter { $0.printerName == printer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a record, trims to capacity, and writes atomically.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = records
|
||||||
|
updated.append(record)
|
||||||
|
if updated.count > capacity {
|
||||||
|
updated.sort { $0.timestamp < $1.timestamp }
|
||||||
|
updated = Array(updated.suffix(capacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
try write(updated)
|
||||||
|
records = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all history and updates disk.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
|
try write([])
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC-4180 CSV export.
|
||||||
|
public func exportCSV() -> String {
|
||||||
|
var lines: [String] = [
|
||||||
|
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
|
||||||
|
]
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
lines.append(csvRow([
|
||||||
|
record.id,
|
||||||
|
record.profileName,
|
||||||
|
record.printerName,
|
||||||
|
String(record.avgDE),
|
||||||
|
String(record.maxDE),
|
||||||
|
String(record.rmsDE),
|
||||||
|
String(record.patchCount),
|
||||||
|
record.status.rawValue,
|
||||||
|
ISO8601DateFormatter().string(from: record.timestamp)
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `records` through a temp file and rename.
|
||||||
|
private func write(_ records: [VerificationRecord]) throws {
|
||||||
|
try fileStore.save(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func csvRow(_ fields: [String]) -> String {
|
||||||
|
fields.map { field in
|
||||||
|
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
|
||||||
|
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
}.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single entry in the verification history store.
|
||||||
|
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
|
||||||
|
public var id: String
|
||||||
|
public var profileName: String
|
||||||
|
public var printerName: String
|
||||||
|
public var avgDE: Double
|
||||||
|
public var maxDE: Double
|
||||||
|
public var rmsDE: Double
|
||||||
|
public var patchCount: Int
|
||||||
|
public var status: VerificationStatus
|
||||||
|
public var timestamp: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
profileName: String,
|
||||||
|
printerName: String,
|
||||||
|
avgDE: Double,
|
||||||
|
maxDE: Double,
|
||||||
|
rmsDE: Double,
|
||||||
|
patchCount: Int,
|
||||||
|
status: VerificationStatus,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.profileName = profileName
|
||||||
|
self.printerName = printerName
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.status = status
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// ICCery quality band for a verification run.
|
||||||
|
///
|
||||||
|
/// Bands are on the **average** ΔE₀₀:
|
||||||
|
/// - < 1.0 → Excellent
|
||||||
|
/// - < 2.0 → Good
|
||||||
|
/// - < 3.5 → Acceptable
|
||||||
|
/// - ≥ 3.5 → Warning
|
||||||
|
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case excellent
|
||||||
|
case good
|
||||||
|
case acceptable
|
||||||
|
case poor
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .excellent: return "Excellent"
|
||||||
|
case .good: return "Good"
|
||||||
|
case .acceptable: return "Acceptable"
|
||||||
|
case .poor: return "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the quality band for the given average ΔE₀₀.
|
||||||
|
public static func from(avgDE: Double) -> VerificationStatus {
|
||||||
|
if avgDE < 1.0 { return .excellent }
|
||||||
|
if avgDE < 2.0 { return .good }
|
||||||
|
if avgDE < 3.5 { return .acceptable }
|
||||||
|
return .poor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension TargenConfig {
|
||||||
|
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
|
||||||
|
/// `nil` when the preset omitted them so argv builders skip the flag.
|
||||||
|
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
|
||||||
|
self.init(
|
||||||
|
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
|
||||||
|
patchCount: preset.patchCount,
|
||||||
|
whitePatches: preset.whitePatches,
|
||||||
|
blackPatches: preset.blackPatches,
|
||||||
|
greySteps: preset.greySteps,
|
||||||
|
singleChannelSteps: preset.singleChannelSteps,
|
||||||
|
neutralSteps: preset.neutralSteps,
|
||||||
|
neutralConcentration: preset.neutralConcentration,
|
||||||
|
preconditioningProfile: preset.preconditioningProfile,
|
||||||
|
// An explicit `false` is preserved — distinguishable from a
|
||||||
|
// missing key; `-G` is only emitted for `true` (#82).
|
||||||
|
ofpsHighQuality: preset.ofpsHighQuality,
|
||||||
|
ofpsAdaptation: preset.ofpsAdaptation,
|
||||||
|
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||||
|
totalInkLimit: preset.totalInkLimit,
|
||||||
|
darkEmphasis: preset.darkEmphasis,
|
||||||
|
devicePower: preset.devicePower,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrinttargConfig {
|
||||||
|
public init(
|
||||||
|
preset: ProfilingPreset,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL?,
|
||||||
|
calibrationFile: String?,
|
||||||
|
label: String? = nil
|
||||||
|
) {
|
||||||
|
let page: PageSize
|
||||||
|
let customW: Double
|
||||||
|
let customH: Double
|
||||||
|
if let size = PageSize(rawValue: preset.pageSize) {
|
||||||
|
page = size
|
||||||
|
customW = 210
|
||||||
|
customH = 297
|
||||||
|
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
|
||||||
|
page = .custom
|
||||||
|
customW = w
|
||||||
|
customH = h
|
||||||
|
} else {
|
||||||
|
page = .a4
|
||||||
|
customW = 210
|
||||||
|
customH = 297
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout: LayoutOrder
|
||||||
|
let seed = preset.randomSeed ?? 1
|
||||||
|
if preset.noRandomize == true {
|
||||||
|
layout = .raster
|
||||||
|
} else if seed == 1 {
|
||||||
|
layout = .deterministic
|
||||||
|
} else {
|
||||||
|
layout = .customSeed
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
|
||||||
|
pageSize: page,
|
||||||
|
customPageWidth: customW,
|
||||||
|
customPageHeight: customH,
|
||||||
|
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
|
||||||
|
dpi: preset.dpi,
|
||||||
|
layoutOrder: layout,
|
||||||
|
customSeed: seed,
|
||||||
|
label: label,
|
||||||
|
calibrationFile: calibrationFile,
|
||||||
|
calibrationEmbedOnly: false,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ColprofConfig {
|
||||||
|
public init(
|
||||||
|
preset: ProfilingPreset,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL?,
|
||||||
|
description: String? = nil,
|
||||||
|
copyright: String? = nil
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
algorithm: preset.colprofAlgorithm ?? "l",
|
||||||
|
quality: preset.colprofQuality ?? "m",
|
||||||
|
intent: Self.nilIfEmpty(preset.colprofIntent),
|
||||||
|
fwa: preset.colprofFwa,
|
||||||
|
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
|
||||||
|
observer: Self.nilIfEmpty(preset.colprofObserver),
|
||||||
|
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
|
||||||
|
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
|
||||||
|
description: description,
|
||||||
|
copyright: copyright,
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nilIfEmpty(_ value: String?) -> String? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing FWA selection for the Stage 4 form, plus the two
|
||||||
|
/// directions of `colprof_fwa` conversion centralised here so the view
|
||||||
|
/// models carry no mapping switches of their own (#82).
|
||||||
|
public enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||||
|
case none = "none"
|
||||||
|
case empty = ""
|
||||||
|
case D50 = "D50"
|
||||||
|
case D65 = "D65"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .empty: return "Bare (-f)"
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return "Custom .sp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preset `colprof_fwa` → selection. `nil`/`"none"` map to `.none`,
|
||||||
|
/// `""` to `.empty`, `D50`/`D65` case-insensitively, and any other
|
||||||
|
/// string is a custom `.sp` path.
|
||||||
|
public init(presetValue: String?) {
|
||||||
|
switch presetValue?.lowercased() {
|
||||||
|
case nil, "none": self = .none
|
||||||
|
case "": self = .empty
|
||||||
|
case "d50": self = .D50
|
||||||
|
case "d65": self = .D65
|
||||||
|
default: self = .custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection → `colprof_fwa` value. `.custom` returns `customPath`.
|
||||||
|
public func presetValue(customPath: String) -> String? {
|
||||||
|
switch self {
|
||||||
|
case .none: return nil
|
||||||
|
case .empty: return ""
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return customPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PageSize {
|
||||||
|
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||||
|
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||||
|
let parts = raw.lowercased().split(separator: "x")
|
||||||
|
guard parts.count == 2,
|
||||||
|
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||||
|
w >= 50, h >= 50 else { return nil }
|
||||||
|
return (w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfilingPreset {
|
||||||
|
/// Snapshot of the three live configs plus calibration toggles.
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
targen: TargenConfig,
|
||||||
|
printtarg: PrinttargConfig,
|
||||||
|
colprof: ColprofConfig,
|
||||||
|
calibrationFile: String?,
|
||||||
|
applyCalibration: Bool?
|
||||||
|
) {
|
||||||
|
let pageSize: String
|
||||||
|
if printtarg.pageSize == .custom {
|
||||||
|
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
|
||||||
|
} else {
|
||||||
|
pageSize = printtarg.pageSize.rawValue
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||||
|
patchCount: targen.patchCount,
|
||||||
|
whitePatches: targen.whitePatches,
|
||||||
|
blackPatches: targen.blackPatches,
|
||||||
|
greySteps: targen.greySteps,
|
||||||
|
singleChannelSteps: targen.singleChannelSteps,
|
||||||
|
neutralSteps: targen.neutralSteps,
|
||||||
|
neutralConcentration: targen.neutralConcentration,
|
||||||
|
preconditioningProfile: targen.preconditioningProfile,
|
||||||
|
ofpsHighQuality: targen.ofpsHighQuality,
|
||||||
|
ofpsAdaptation: targen.ofpsAdaptation,
|
||||||
|
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
|
||||||
|
totalInkLimit: targen.totalInkLimit,
|
||||||
|
darkEmphasis: targen.darkEmphasis,
|
||||||
|
devicePower: targen.devicePower,
|
||||||
|
instrument: printtarg.instrument.rawValue,
|
||||||
|
pageSize: pageSize,
|
||||||
|
bitDepth: printtarg.bitDepth.rawValue,
|
||||||
|
dpi: printtarg.dpi,
|
||||||
|
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
|
||||||
|
noRandomize: printtarg.layoutOrder == .raster,
|
||||||
|
calibrationFile: calibrationFile,
|
||||||
|
applyCalibration: applyCalibration,
|
||||||
|
colprofAlgorithm: colprof.algorithm,
|
||||||
|
colprofQuality: colprof.quality,
|
||||||
|
colprofIntent: colprof.intent,
|
||||||
|
colprofFwa: colprof.fwa,
|
||||||
|
colprofIlluminant: colprof.illuminant,
|
||||||
|
colprofObserver: colprof.observer,
|
||||||
|
colprofInputViewingCond: colprof.inputViewingCond,
|
||||||
|
colprofOutputViewingCond: colprof.outputViewingCond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
|
|||||||
|
|
||||||
/// Single-preset pretty JSON export.
|
/// Single-preset pretty JSON export.
|
||||||
public func export(_ preset: ProfilingPreset) throws -> Data {
|
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||||
let encoder = JSONEncoder()
|
return try JSONEncoder.icceryPretty().encode(preset)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
return try encoder.encode(preset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses + validates a preset from JSON. The preset is assigned a
|
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Foundation
|
|||||||
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||||
/// (issue #5 — the v1 path is never read).
|
/// (issue #5 — the v1 path is never read).
|
||||||
///
|
///
|
||||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt
|
||||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||||
/// reclassify swatches.
|
/// reclassify swatches.
|
||||||
public final class SettingsStore: Sendable {
|
public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
|
|||||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||||
|
|
||||||
public let fileURL: URL
|
public let fileURL: URL
|
||||||
|
private let store: JSONFileStore<AppSettings>
|
||||||
|
|
||||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
|
self.store = JSONFileStore(
|
||||||
|
fileURL: fileURL,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func load() -> AppSettings {
|
public func load() -> AppSettings {
|
||||||
guard let data = try? Data(contentsOf: fileURL),
|
(try? store.load()) ?? .default
|
||||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
|
||||||
else {
|
|
||||||
return .default
|
|
||||||
}
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates before persisting — throws `SettingsError` listing
|
/// Validates before persisting — throws `SettingsError` listing
|
||||||
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
|
|||||||
guard errors.isEmpty else {
|
guard errors.isEmpty else {
|
||||||
throw SettingsError.validationFailed(errors)
|
throw SettingsError.validationFailed(errors)
|
||||||
}
|
}
|
||||||
let encoder = JSONEncoder()
|
try store.save(settings)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
|
||||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,28 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
public var sessionMode: SessionMode
|
public var sessionMode: SessionMode
|
||||||
/// May differ from `basename` after a `.ti3` import (#94).
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
public var profileBasename: String?
|
public var profileBasename: String?
|
||||||
|
/// The pre-`CAL_` basename, persisted so a crash/relaunch can
|
||||||
|
/// restore the original (#29).
|
||||||
|
public var calibrationOriginalBasename: String = ""
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case currentStage, basename, cwd, printerName, sessionMode
|
||||||
|
case profileBasename, calibrationOriginalBasename
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.currentStage = try container.decodeIfPresent(Int.self, forKey: .currentStage)
|
||||||
|
?? WizardStage.generate.rawValue
|
||||||
|
self.basename = try container.decodeIfPresent(String.self, forKey: .basename) ?? ""
|
||||||
|
self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) ?? ""
|
||||||
|
self.printerName = try container.decodeIfPresent(String.self, forKey: .printerName)
|
||||||
|
self.sessionMode = try container.decodeIfPresent(SessionMode.self, forKey: .sessionMode)
|
||||||
|
?? .profile
|
||||||
|
self.profileBasename = try container.decodeIfPresent(String.self, forKey: .profileBasename)
|
||||||
|
self.calibrationOriginalBasename = try container.decodeIfPresent(
|
||||||
|
String.self, forKey: .calibrationOriginalBasename) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
currentStage: Int = WizardStage.generate.rawValue,
|
currentStage: Int = WizardStage.generate.rawValue,
|
||||||
@@ -28,7 +50,8 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
cwd: String = "",
|
cwd: String = "",
|
||||||
printerName: String? = nil,
|
printerName: String? = nil,
|
||||||
sessionMode: SessionMode = .profile,
|
sessionMode: SessionMode = .profile,
|
||||||
profileBasename: String? = nil
|
profileBasename: String? = nil,
|
||||||
|
calibrationOriginalBasename: String = ""
|
||||||
) {
|
) {
|
||||||
self.currentStage = currentStage
|
self.currentStage = currentStage
|
||||||
self.basename = basename
|
self.basename = basename
|
||||||
@@ -36,6 +59,7 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
self.printerName = printerName
|
self.printerName = printerName
|
||||||
self.sessionMode = sessionMode
|
self.sessionMode = sessionMode
|
||||||
self.profileBasename = profileBasename
|
self.profileBasename = profileBasename
|
||||||
|
self.calibrationOriginalBasename = calibrationOriginalBasename
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = WizardState()
|
public static let `default` = WizardState()
|
||||||
@@ -50,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
|
|||||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||||
public final class WizardStateStore: Sendable {
|
public final class WizardStateStore: Sendable {
|
||||||
public let fileURL: URL
|
public let fileURL: URL
|
||||||
|
private let store: JSONFileStore<WizardState>
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||||
) {
|
) {
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
|
self.store = JSONFileStore(
|
||||||
|
fileURL: fileURL,
|
||||||
|
corrupt: .replaceWithDefault,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func load() -> WizardState {
|
public func load() -> WizardState {
|
||||||
guard let data = try? Data(contentsOf: fileURL),
|
(try? store.load()) ?? .default
|
||||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
|
||||||
else { return .default }
|
|
||||||
return state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func save(_ state: WizardState) throws {
|
public func save(_ state: WizardState) throws {
|
||||||
let encoder = JSONEncoder()
|
try store.save(state)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,324 @@
|
|||||||
# iccery-v2-mac
|
# 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.
|
||||||
|
|
||||||
|
**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 |
|
||||||
|
| Bundle | `com.gronod.iccery2` |
|
||||||
|
| Version | 2.0.3 |
|
||||||
|
| 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` |
|
||||||
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
|
||||||
|
| M7 | Pre-UAT hardening — shipped |
|
||||||
|
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||||
|
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
|
||||||
|
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
|
||||||
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
The wizard is artefact-gated:
|
||||||
|
|
||||||
|
1. **Stage 1** — `targen` → `.ti1`
|
||||||
|
2. **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 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
||||||
|
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||||
|
|
||||||
|
Plus:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
To **run** a packaged build:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.i3omb.com/gronod/iccery-v2-mac.git
|
||||||
|
cd iccery-v2-mac
|
||||||
|
git checkout develop
|
||||||
|
|
||||||
|
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
||||||
|
make test # xcodegen + xcodebuild build test (host arch)
|
||||||
|
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent without Make:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodegen generate
|
||||||
|
xcodebuild test -scheme ICCery \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
ARCHS="$(uname -m)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
# optional
|
||||||
|
export ARGYLL_SERVER_URL=https://git.i3omb.com
|
||||||
|
export ARGYLL_REPO=gronod/argyllcms
|
||||||
|
export ARGYLL_RELEASE_TAG=… # default: latest
|
||||||
|
export GITEA_TOKEN=… # private releases
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Release packaging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds with a fixed derived data path (`build/DerivedData`), locates
|
||||||
|
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||||
|
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||||
|
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||||
|
writes the DMG with `dmgbuild==1.6.7`.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
`scripts/version.sh` resolves the version triple and is the single source for
|
||||||
|
packaging and CI:
|
||||||
|
|
||||||
|
- **`ICCERY_RELEASE_TAG`** — `RELEASE_TAG` env when it matches `v[0-9]*` (CI
|
||||||
|
tag builds), else `git describe --tags --always --dirty --match 'v[0-9]*'`.
|
||||||
|
Stamped into the bundle's `ICCeryReleaseTag` resource (a generated
|
||||||
|
Info.plist cannot carry custom keys); the About dialog shows
|
||||||
|
`tag (marketing)` — e.g. `v2.0.0-pre2-grok (2.0.0)` — falling back to the
|
||||||
|
plain version when untagged.
|
||||||
|
- **`MARKETING_VERSION`** (`CFBundleShortVersionString`) — first three numeric
|
||||||
|
components of the tag core. Must equal `project.yml`'s
|
||||||
|
`MARKETING_VERSION` on tag builds — packaging hard-fails on mismatch, so
|
||||||
|
bump `project.yml` on `develop` *before* tagging.
|
||||||
|
- **`CURRENT_PROJECT_VERSION`** (`CFBundleVersion`) — `BUILD_NUMBER` env
|
||||||
|
override, else `git rev-list --count HEAD`. A single monotonically
|
||||||
|
increasing integer per Apple's macOS convention (Mac build numbers never
|
||||||
|
reset per version, unlike iOS).
|
||||||
|
|
||||||
|
Tagged/described DMGs carry the tag: `ICCery-2.0.0-pre2-grok-<build>.dmg`;
|
||||||
|
plain releases keep `ICCery-<ver>-<build>.dmg`.
|
||||||
|
|
||||||
|
Release procedure: bump `MARKETING_VERSION` in `project.yml` on `develop` →
|
||||||
|
merge `develop` → `main` → tag the release commit `vX.Y.Z[-suffix]` → push
|
||||||
|
the tag. CI builds, signs, notarizes (when secrets exist) and attaches the
|
||||||
|
DMG to the release.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GITEA_TOKEN` | private `gronod/argyllcms` release downloads |
|
||||||
|
| `ARGYLL_SERVER_URL` / `ARGYLL_REPO` / `ARGYLL_RELEASE_TAG` | sidecar release override |
|
||||||
|
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
|
||||||
|
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
|
||||||
|
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
|
||||||
|
| `RELEASE_TAG` | release tag string (CI sets `github.ref_name`); stamped into the bundle + DMG name |
|
||||||
|
| `BUILD_NUMBER` | `CFBundleVersion` override; default `git rev-list --count HEAD` |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/ICCery/ SwiftUI + AppKit shell, stage views, workflow VMs
|
||||||
|
Packages/ICCeryCore/ wizard state, ProcessManager, argv builders,
|
||||||
|
settings, CGATS, ΔE₀₀ — no NSPrintPanel
|
||||||
|
Resources/ assets; Argyll reference files (not the tools)
|
||||||
|
Vendor/Argyll/ fetched sidecars (gitignored)
|
||||||
|
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
||||||
|
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
||||||
|
scripts/ensure-host-tools.sh
|
||||||
|
scripts/fetch-argyll.sh
|
||||||
|
scripts/package-release.sh
|
||||||
|
docs/ functional spec + v2 ticket plan
|
||||||
|
```
|
||||||
|
|
||||||
|
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
|
||||||
|
not in this tree.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
|
||||||
|
bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
|
||||||
|
is always set.
|
||||||
|
- **`ProcessManager` actor** owns child lifetime. Streaming tools
|
||||||
|
(`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
|
||||||
|
(`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`, `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 headless `NSPrintOperation` (#201 — no `lp`):
|
||||||
|
restore the captured `PrintTicket`, write both ColorSync vocabularies
|
||||||
|
(locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always
|
||||||
|
win over the ticket, draw 1:1 with interpolation off.
|
||||||
|
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
||||||
|
limit. Split large `VStack`/`Group` trees (#146).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# full suite (host arch) — same as CI
|
||||||
|
xcodebuild test -scheme ICCery \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
|
# fat compile-check (not the default test path):
|
||||||
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
|
# examples
|
||||||
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
|
-only-testing:ICCeryCoreTests/ChartreadClassifierTests
|
||||||
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
|
-only-testing:ICCeryUITests/Milestone5UITests
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Detected via bundled `instlist`:
|
||||||
|
|
||||||
|
- i1 Pro / i1 Pro 2 (`i1`)
|
||||||
|
- ColorMunki (`CM`)
|
||||||
|
- SpyderPrint (`p3`)
|
||||||
|
- SpectroScan (`SS`)
|
||||||
|
- DTP20 / 22 / 41 / 51
|
||||||
|
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
|
||||||
|
`/spectro\s?scan|i1io/i`
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/01-overview.md`](docs/01-overview.md) | Product and wizard |
|
||||||
|
| [`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/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/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 |
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
```
|
||||||
|
develop # integration; PRs land here unless a milestone branch is announced
|
||||||
|
main # protected release line (PR from develop)
|
||||||
|
feat/<issue>-<slug>
|
||||||
|
fix/<issue>-<slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||||
|
branch is used only while that milestone is assembling; `milestone/m10-studio`
|
||||||
|
and `milestone/m11-print-settings` have been merged (branches retained). 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
||||||
|
|||||||
@@ -1,75 +1,79 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mock script for chartread -u
|
# Mock chartread for bundled/manual testing.
|
||||||
# This script simulates the behaviour of chartread for testing purposes.
|
# Supports handheld and XY modes. Writes basename.ti3 on 'd'.
|
||||||
|
MODE="${MOCK_CHARTREAD_MODE:-strip}"
|
||||||
|
BASENAME=""
|
||||||
|
|
||||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
# Basename is the last non-flag argument.
|
||||||
IS_XY=0
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$arg" = "--xy" ]; then
|
case "$arg" in
|
||||||
IS_XY=1
|
-*) ;;
|
||||||
break
|
*) BASENAME="$arg" ;;
|
||||||
fi
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
read_input() {
|
||||||
|
IFS= read -r line || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_row() {
|
||||||
|
printf 'ROW_COLORS_JSON: %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_ti3() {
|
||||||
|
if [ -n "$BASENAME" ]; then
|
||||||
|
echo "MOCK_TI3" > "${BASENAME}.ti3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$MODE" = "xy" ]; then
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
read -r _calib
|
read_input
|
||||||
echo "Calibration successful."
|
echo "Calibration successful."
|
||||||
|
|
||||||
echo "Please place sheet 1 of 1 on the table"
|
echo "Please place sheet 1 of 1 on the table"
|
||||||
echo "hit return to continue, Esc or 'q' to give up"
|
echo "hit return to continue, Esc or 'q' to give up"
|
||||||
read -r _sheet1
|
read_input
|
||||||
|
|
||||||
echo "locate patch A1 with the sight,"
|
echo "locate patch A1 with the sight,"
|
||||||
echo "then hit return to continue"
|
echo "then hit return to continue"
|
||||||
read -r _fid1
|
read_input
|
||||||
|
|
||||||
echo "locate patch B24 with the sight,"
|
|
||||||
echo "then hit return to continue"
|
|
||||||
read -r _fid2
|
|
||||||
|
|
||||||
echo "Reading sheet 1..."
|
echo "Reading sheet 1..."
|
||||||
sleep 0.5
|
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||||
|
|
||||||
# Emit mock JSON for strip A
|
|
||||||
cat << 'EOF'
|
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Emit mock JSON for strip B
|
|
||||||
cat << 'EOF'
|
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Sheet 1 of 1 read OK"
|
echo "Sheet 1 of 1 read OK"
|
||||||
echo "Please remove last sheet from table"
|
echo "Please remove last sheet from table"
|
||||||
|
echo "'d' if/when done"
|
||||||
|
while read_input; do
|
||||||
|
case "$line" in
|
||||||
|
d*) write_ti3; exit 0 ;;
|
||||||
|
q*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handheld / strip reader simulation
|
# Handheld / strip mode (default)
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
read_input
|
||||||
# We don't really wait for input, just wait 1 second
|
|
||||||
sleep 1
|
|
||||||
echo "Calibration successful."
|
echo "Calibration successful."
|
||||||
echo "Hit [Space] to read strip A (or 's' to skip)."
|
|
||||||
|
|
||||||
sleep 1
|
echo "Hit [Space] to read strip A"
|
||||||
|
read_input
|
||||||
echo "Reading strip A..."
|
echo "Reading strip A..."
|
||||||
|
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||||
|
|
||||||
# Emit mock JSON for strip A
|
echo "Hit [Space] to read strip B"
|
||||||
cat << 'EOF'
|
read_input
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Hit [Space] to read strip B (or 's' to skip)."
|
|
||||||
sleep 1
|
|
||||||
echo "Reading strip B..."
|
echo "Reading strip B..."
|
||||||
|
emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}'
|
||||||
|
|
||||||
# Emit mock JSON for strip B
|
echo "'d' if/when done"
|
||||||
cat << 'EOF'
|
while read_input; do
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
case "$line" in
|
||||||
EOF
|
d*) write_ti3; exit 0 ;;
|
||||||
|
q*) exit 0 ;;
|
||||||
echo "Ready to read... done."
|
esac
|
||||||
|
done
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,51 +1,61 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16x16.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_16x16@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32x32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_32x32@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128x128.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_128x128@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256x256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_256x256@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512x512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "icon_512x512@2x.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 603 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Debug only: the shared ICCeryCore package framework embedded in the
|
||||||
|
test products is ad-hoc signed with no Team ID, so hardened-runtime
|
||||||
|
library validation kills the test host at launch (run 31992, #119).
|
||||||
|
Release uses ICCery.entitlements and links the package statically. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,81 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// About dialog for ICCery (issue #31, docs/21 §Modals).
|
||||||
|
struct AboutView: View {
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var showingLicenses = false
|
||||||
|
|
||||||
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if let icon = NSImage(named: NSImage.applicationIconName) {
|
||||||
|
Image(nsImage: icon)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("ICCery")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Version:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.version)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutVersion")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
HStack {
|
||||||
|
Text("Build:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.build)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
HStack {
|
||||||
|
Text("Build date:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.buildDate)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutBuildDate")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
|
||||||
|
Text("Native macOS printer profiling workstation.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("View Licenses") {
|
||||||
|
showingLicenses = true
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityIdentifier("viewLicensesBtn")
|
||||||
|
|
||||||
|
Button("Close") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("closeAboutBtn")
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 360)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
.sheet(isPresented: $showingLicenses) {
|
||||||
|
LicenseWindowView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ struct AppEnvironment: Sendable {
|
|||||||
let presetStore: PresetStore
|
let presetStore: PresetStore
|
||||||
let runner: ArgyllRunner
|
let runner: ArgyllRunner
|
||||||
let cupsService: CupsService
|
let cupsService: CupsService
|
||||||
|
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
|
||||||
@@ -19,14 +22,24 @@ 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")
|
||||||
|
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
|
||||||
#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)
|
||||||
}
|
}
|
||||||
|
// #214 — fixture PPD dir lets UI tests exercise the media→
|
||||||
|
// quality constraint resolver without installed drivers.
|
||||||
|
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
|
||||||
|
ppdDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
return AppEnvironment(
|
return AppEnvironment(
|
||||||
stateStore: WizardStateStore(),
|
stateStore: WizardStateStore(),
|
||||||
@@ -34,11 +47,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, ppdDir: ppdDir),
|
||||||
|
historyStore: VerificationHistoryStore(),
|
||||||
|
mediaStore: MediaLibraryStore(),
|
||||||
|
recentProjectsStore: RecentProjectsStore()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,19 +83,43 @@ enum UITestHooks {
|
|||||||
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
||||||
/// `select_directory` result (working-directory browse).
|
/// `select_directory` result (working-directory browse).
|
||||||
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
||||||
|
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
|
||||||
|
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
|
||||||
/// Preset import file.
|
/// Preset import file.
|
||||||
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)
|
||||||
|
|
||||||
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
/// Directory of mock `lpstat`/`lpoptions` fixture scripts —
|
||||||
/// `CupsService.binaryDir` under UI tests.
|
/// `CupsService.binaryDir` under UI tests.
|
||||||
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||||
|
|
||||||
/// Path the mock `lp` script appends its argv to, for assertions.
|
/// Path the `RecordingTargetSpooler` appends resolved ticket
|
||||||
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
/// lines to, for assertions (#201 D8 — replaces the `lp` argv
|
||||||
|
/// log).
|
||||||
|
static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") }
|
||||||
|
|
||||||
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||||
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||||
@@ -92,20 +133,42 @@ enum UITestHooks {
|
|||||||
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
||||||
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
||||||
/// queue the panel was opened for).
|
/// queue the panel was opened for).
|
||||||
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
|
static func printPanelResult(
|
||||||
|
forQueue queue: String,
|
||||||
|
trays: [PrinterTray] = [],
|
||||||
|
preferredTrayKey: String? = nil
|
||||||
|
) -> PrintPropertiesResult? {
|
||||||
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
||||||
case "ok":
|
case "ok":
|
||||||
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
}
|
}
|
||||||
|
let trayToken = options.flatMap {
|
||||||
|
CupsParsers.extractTray(
|
||||||
|
fromOptionsString: $0, preferredKey: preferredTrayKey)
|
||||||
|
}
|
||||||
|
let paperSource = trays.first(where: {
|
||||||
|
$0.token == trayToken
|
||||||
|
}).map(\.id)
|
||||||
return PrintPropertiesResult(
|
return PrintPropertiesResult(
|
||||||
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
||||||
$0.isEmpty ? nil : $0
|
$0.isEmpty ? nil : $0
|
||||||
} ?? queue,
|
} ?? queue,
|
||||||
options: PrintOptions(
|
options: PrintOptions(
|
||||||
|
paperSource: paperSource,
|
||||||
|
orientation: options.flatMap {
|
||||||
|
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||||
|
},
|
||||||
|
paperSize: options.flatMap {
|
||||||
|
CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: $0)
|
||||||
|
},
|
||||||
mediaType: options.flatMap {
|
mediaType: options.flatMap {
|
||||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||||
},
|
},
|
||||||
|
quality: options.flatMap {
|
||||||
|
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||||
|
},
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: options))
|
cupsOptions: options))
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||||
|
struct CalibrationView: View {
|
||||||
|
@ObservedObject var model: CalibrationViewModel
|
||||||
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Calibrate Printer")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
wedgeSection
|
||||||
|
workflowSection
|
||||||
|
if !model.calibrationLog.isEmpty { logSection }
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
|
||||||
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("btnCalReturn")
|
||||||
|
}
|
||||||
|
.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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||||
|
/// compute `.cal` curves.
|
||||||
|
@MainActor
|
||||||
|
final class CalibrationViewModel: ObservableObject {
|
||||||
|
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
let profile: ProfileWorkflowViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
// MARK: - Form state
|
||||||
|
|
||||||
|
@Published var colourSpace: ColourSpace = .cmyk
|
||||||
|
@Published var steps: Int = 21
|
||||||
|
@Published var whitePatches: Int = 4
|
||||||
|
@Published var includeNeutralEmphasis: Bool = false
|
||||||
|
@Published var inkLimit: String = "320"
|
||||||
|
@Published var applyToProfile: Bool = false
|
||||||
|
@Published var computedCalURL: URL?
|
||||||
|
@Published var calibrationLog: [String] = []
|
||||||
|
@Published var isGenerating = false
|
||||||
|
@Published var isComputing = false
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self.profile = profile
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wizard: WizardViewModel { workflow.wizard }
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var canGenerate: Bool {
|
||||||
|
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating
|
||||||
|
}
|
||||||
|
|
||||||
|
var canCompute: Bool {
|
||||||
|
calibrationTi3URL != nil && !isComputing
|
||||||
|
}
|
||||||
|
|
||||||
|
var calibrationTi3URL: URL? {
|
||||||
|
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||||
|
return cwd.appendingPathComponent("\(calBasename).ti3")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var identity: CalibrationIdentity {
|
||||||
|
CalibrationIdentity.parse(
|
||||||
|
liveBasename: wizard.basename,
|
||||||
|
persistedOriginal: wizard.calibrationOriginalBasename
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calBasename: String { identity.calibrationBasename }
|
||||||
|
|
||||||
|
private var calOutputURL: URL? {
|
||||||
|
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||||
|
return cwd.appendingPathComponent("\(calBasename).cal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generate calibration target
|
||||||
|
|
||||||
|
func generateTarget() {
|
||||||
|
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||||
|
let identity = CalibrationIdentity.parse(
|
||||||
|
liveBasename: wizard.basename,
|
||||||
|
persistedOriginal: wizard.calibrationOriginalBasename
|
||||||
|
)
|
||||||
|
wizard.calibrationOriginalBasename = identity.originalBasename
|
||||||
|
wizard.basename = identity.calibrationBasename
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
|
||||||
|
let config = CalibrationTargenConfig(
|
||||||
|
colourSpace: colourSpace,
|
||||||
|
steps: steps,
|
||||||
|
whitePatches: whitePatches,
|
||||||
|
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||||
|
inkLimit: inkLimitValue,
|
||||||
|
basename: identity.originalBasename,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
_ = try await ProcessRunSupport.runLogged(
|
||||||
|
setRunning: { self.isGenerating = $0 },
|
||||||
|
resetLog: { self.calibrationLog = [] },
|
||||||
|
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await self.environment.runner.runCalibrationTargen(
|
||||||
|
config: config, onLogBatch: onLog)
|
||||||
|
}
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.wizard.showNotice("Calibration target generated.")
|
||||||
|
self.wizard.go(to: .layOutPrint)
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Calibration target failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
self.wizard.restoreCalibration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout, print, measure
|
||||||
|
|
||||||
|
/// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename.
|
||||||
|
/// After measurement, the user returns and presses Compute Curves.
|
||||||
|
func createLayout() {
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
wizard.go(to: .layOutPrint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureChart() {
|
||||||
|
wizard.sessionMode = .calibration
|
||||||
|
wizard.go(to: .measure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compute curves
|
||||||
|
|
||||||
|
func computeCurves() {
|
||||||
|
guard canCompute,
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory,
|
||||||
|
let outputURL = calOutputURL else { return }
|
||||||
|
|
||||||
|
// Collision check: the Argyll `printcal` exit error contains
|
||||||
|
// "already exists" when the user declines overwrite. We do not
|
||||||
|
// silently clobber.
|
||||||
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||||
|
wizard.showNotice(
|
||||||
|
"\(outputURL.lastPathComponent) already exists. Rename or overwrite it first.",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = PrintcalConfig(
|
||||||
|
ti3Basename: calBasename,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
outputURL: outputURL,
|
||||||
|
noInkLimit: false,
|
||||||
|
verify: false,
|
||||||
|
previousCalPath: nil,
|
||||||
|
totalInkLimit: inkLimitValue.map { Double($0) },
|
||||||
|
channelLimits: []
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let url = try await ProcessRunSupport.runLogged(
|
||||||
|
setRunning: { self.isComputing = $0 },
|
||||||
|
resetLog: { self.calibrationLog = [] },
|
||||||
|
onLog: { self.calibrationLog.append(contentsOf: $0) }
|
||||||
|
) { onLog in
|
||||||
|
try await self.environment.runner.runPrintcal(
|
||||||
|
config: config, onLogBatch: onLog)
|
||||||
|
}
|
||||||
|
self.computedCalURL = url
|
||||||
|
self.profile.calibrationFile = url.path
|
||||||
|
self.profile.applyCalibration = self.applyToProfile
|
||||||
|
self.wizard.showNotice("Calibration curves computed.")
|
||||||
|
self.wizard.restoreCalibration()
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Calibration curve computation failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply toggle
|
||||||
|
|
||||||
|
func updateApplyToProfile() {
|
||||||
|
profile.applyCalibration = applyToProfile
|
||||||
|
if applyToProfile, let url = computedCalURL {
|
||||||
|
profile.calibrationFile = url.path
|
||||||
|
} else if applyToProfile {
|
||||||
|
// User toggled on before computing; keep the path if already set.
|
||||||
|
} else {
|
||||||
|
profile.applyCalibration = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func returnToProfiling() {
|
||||||
|
wizard.restoreCalibration()
|
||||||
|
wizard.go(to: .generate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inkLimitValue: Int? {
|
||||||
|
guard colourSpace == .cmyk else { return nil }
|
||||||
|
return Int(inkLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Minimal line chart for drift history without depending on the
|
||||||
|
/// `Charts` framework link. Renders avg/max series with shaded quality
|
||||||
|
/// bands.
|
||||||
|
struct DriftChartView: View {
|
||||||
|
let records: [VerificationRecord]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = geometry.size.width
|
||||||
|
let height = geometry.size.height
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if let (_, _, _, maxV) = scales(in: height) {
|
||||||
|
// Quality bands — bottom (red, > 3.5) drawn first, then
|
||||||
|
// orange, yellow, green so the upper-most bands overlay.
|
||||||
|
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
|
||||||
|
// Average ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.avgDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftAvgSeries")
|
||||||
|
|
||||||
|
// Max ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.maxDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.orange, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftMaxSeries")
|
||||||
|
} else {
|
||||||
|
Text("No data")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func band(
|
||||||
|
from lower: Double,
|
||||||
|
to upper: Double,
|
||||||
|
color: Color,
|
||||||
|
height: CGFloat,
|
||||||
|
maxValue: Double
|
||||||
|
) -> some View {
|
||||||
|
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
return color
|
||||||
|
.frame(height: yBottom - yTop)
|
||||||
|
.offset(y: yTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
|
||||||
|
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
|
||||||
|
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
|
||||||
|
return (minT, maxT, 0.0, maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func point(
|
||||||
|
for record: VerificationRecord,
|
||||||
|
minTime: Date,
|
||||||
|
maxTime: Date,
|
||||||
|
minValue: Double,
|
||||||
|
maxValue: Double,
|
||||||
|
width: CGFloat,
|
||||||
|
height: CGFloat,
|
||||||
|
keyPath: KeyPath<VerificationRecord, Double>
|
||||||
|
) -> CGPoint {
|
||||||
|
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
|
||||||
|
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
|
||||||
|
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
|
||||||
|
return CGPoint(x: x, y: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
|
||||||
|
let valueSpan = max(1, maxValue - minValue)
|
||||||
|
return height - height * CGFloat((value - minValue) / valueSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||