Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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`)
|
||||
ICCery.xcodeproj/
|
||||
|
||||
# Local-only developer helpers (never committed)
|
||||
/set-version.sh
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Release artefacts (not git blobs)
|
||||
*.dmg
|
||||
*.zip
|
||||
Release/
|
||||
notarization/
|
||||
build/
|
||||
docs/megaplans/*
|
||||
docs/megaplans
|
||||
|
||||
@@ -39,17 +39,36 @@ Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
||||
User-supplied strings via SwiftUI `Text` only (#114).
|
||||
TIFF never rendered directly — host-side PNG preview (#58).
|
||||
|
||||
## 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
|
||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||
|
||||
## 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>
|
||||
```
|
||||
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||
|
||||
## Private ColorSync SPI
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Issue **16 is not an M3 or M6 exit gate.**
|
||||
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`.
|
||||
|
||||
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||
|
||||
## 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
|
||||
|
||||
let package = Package(
|
||||
name: "ICCeryCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
platforms: [.macOS(.v12)],
|
||||
products: [
|
||||
.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
|
||||
|
||||
/// Errors from `ArgyllRunner` executions.
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable {
|
||||
case processFailed(code: Int32, logs: [String])
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
case toolFailed(tool: String, code: Int32, logs: [String])
|
||||
case missingArtefact(String)
|
||||
case malformedManifest(String)
|
||||
case instrumentDetectionFailed(String)
|
||||
case profcheckUnparseable
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let code, _):
|
||||
return "Process exited with code \(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)"
|
||||
}
|
||||
case .missingArtefact(let path):
|
||||
return "Expected output file was not created: \(path)"
|
||||
case .malformedManifest(let 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||
@@ -60,26 +127,16 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("targen")
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "targen",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
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")
|
||||
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||
}
|
||||
return ti1URL
|
||||
return try requireArtefact(ti1URL)
|
||||
}
|
||||
|
||||
// MARK: - printtarg (Stage 2)
|
||||
@@ -94,25 +151,15 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try PrinttargArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("printtarg")
|
||||
let processId = ProcessID.printtarg(cleanBasename)
|
||||
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
let run = try await runStreamingTool(
|
||||
name: "printtarg",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
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 ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
||||
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
||||
}
|
||||
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
|
||||
|
||||
let manifest: PrinttargManifest
|
||||
do {
|
||||
@@ -145,22 +192,54 @@ public struct ArgyllRunner: Sendable {
|
||||
|
||||
// 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 {
|
||||
var exitCode: Int32?
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var lines: [String]
|
||||
}
|
||||
|
||||
/// Drains the event stream until this child's `exit` event.
|
||||
/// stdout is accumulated both per-line (logs) and verbatim (for
|
||||
/// 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(
|
||||
id processId: String,
|
||||
events: AsyncStream<ProcessEvent>,
|
||||
onLogBatch: (@Sendable ([String]) -> Void)?
|
||||
onLogBatch: (@Sendable ([String]) -> Void)?,
|
||||
flushPartialLines: Bool = false
|
||||
) async -> CollectedRun {
|
||||
var lines: [String] = []
|
||||
var stdout = ""
|
||||
var stderr = ""
|
||||
var pendingBatch: [String] = []
|
||||
var exitCode: Int32?
|
||||
var lastFlush = Date()
|
||||
@@ -172,6 +251,17 @@ public struct ArgyllRunner: Sendable {
|
||||
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 {
|
||||
guard event.id == processId else { continue }
|
||||
switch event {
|
||||
@@ -181,6 +271,7 @@ public struct ArgyllRunner: Sendable {
|
||||
pendingBatch.append(line)
|
||||
case .stderr(_, let line):
|
||||
lines.append(line)
|
||||
stderr += line + "\n"
|
||||
pendingBatch.append(line)
|
||||
case .error(_, let message):
|
||||
lines.append("Error: \(message)")
|
||||
@@ -202,6 +293,717 @@ public struct ArgyllRunner: Sendable {
|
||||
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/`).
|
||||
public func referenceGamut(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
let stem = name.hasSuffix(".gam") ? name : "\(name).gam"
|
||||
return bundledRoot
|
||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.appendingPathComponent(stem, isDirectory: false)
|
||||
}
|
||||
|
||||
/// Whether the resolved path exists and is executable.
|
||||
|
||||
@@ -56,23 +56,19 @@ public enum PrinttargArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||
case .raster:
|
||||
args.append("-r")
|
||||
args.append(contentsOf: ArgsBuilder.flag("-r", when: true))
|
||||
}
|
||||
|
||||
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!label.isEmpty {
|
||||
args.append(contentsOf: ["-d", label])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-d", config.label))
|
||||
|
||||
guard (72...600).contains(config.dpi) else {
|
||||
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||
}
|
||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||
|
||||
if !cleanBasename.hasPrefix("CAL_"),
|
||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!cal.isEmpty {
|
||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||
if !CalibrationIdentity.isCalibration(cleanBasename) {
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty(
|
||||
config.calibrationEmbedOnly ? "-I" : "-K", config.calibrationFile))
|
||||
}
|
||||
|
||||
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 {
|
||||
args.append(contentsOf: ["-n", "\(n)"])
|
||||
}
|
||||
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
||||
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
||||
}
|
||||
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
||||
args.append(contentsOf: ["-c", c])
|
||||
}
|
||||
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)])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-N", config.neutralConcentration, skip: 0.50))
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-c", config.preconditioningProfile))
|
||||
args.append(contentsOf: ArgsBuilder.flag("-G", when: config.ofpsHighQuality == true))
|
||||
args.append(contentsOf: ArgsBuilder.option("-A", config.ofpsAdaptation.map {
|
||||
String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), $0)
|
||||
}))
|
||||
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||
args.append(algFlag)
|
||||
}
|
||||
@@ -91,11 +85,9 @@ public enum TargenArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||
}
|
||||
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
||||
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
||||
}
|
||||
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(contentsOf: ArgsBuilder.optionUnlessApprox("-V", config.darkEmphasis, skip: 1.0))
|
||||
if let p = config.devicePower, p > 0 {
|
||||
args.append(contentsOf: ArgsBuilder.optionUnlessApprox("-p", p, skip: 1.0))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// `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(
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String) {
|
||||
) -> (version: String, build: String, buildDate: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
return (
|
||||
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
||||
info["CFBundleVersion"] as? String ?? "0"
|
||||
)
|
||||
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||
let build = 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
|
||||
/// Absolute path of the profile file when present.
|
||||
public var profilePath: URL?
|
||||
/// Absolute path of the `.gam` gamut mesh when present (issue #28).
|
||||
public var gamPath: URL?
|
||||
|
||||
public init(
|
||||
stage1Complete: Bool = false,
|
||||
stage2Complete: Bool = false,
|
||||
stage3Complete: Bool = false,
|
||||
stage4Complete: Bool = false,
|
||||
profilePath: URL? = nil
|
||||
profilePath: URL? = nil,
|
||||
gamPath: URL? = nil
|
||||
) {
|
||||
self.stage1Complete = stage1Complete
|
||||
self.stage2Complete = stage2Complete
|
||||
self.stage3Complete = stage3Complete
|
||||
self.stage4Complete = stage4Complete
|
||||
self.profilePath = profilePath
|
||||
self.gamPath = gamPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +49,10 @@ public enum ArtefactProbe {
|
||||
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||
out.stage4Complete = true
|
||||
out.profilePath = profile
|
||||
let gam = artefact(basename, "gam", cwd)
|
||||
if exists(gam, fm: fileManager) {
|
||||
out.gamPath = gam
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -70,6 +78,21 @@ public enum ArtefactProbe {
|
||||
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).
|
||||
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`
|
||||
///
|
||||
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
||||
/// against an isolated root and never touch the developer's state.
|
||||
/// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
|
||||
/// data so UI tests run against an isolated root and never touch the
|
||||
/// developer's state.
|
||||
public static var appDataDir: URL {
|
||||
#if DEBUG
|
||||
if let root = testRoot {
|
||||
@@ -42,10 +43,31 @@ public enum AppPaths {
|
||||
}
|
||||
|
||||
#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? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||
!raw.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||
!raw.isEmpty {
|
||||
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
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import Foundation
|
||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||
public enum ProcessID {
|
||||
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 printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||
|
||||
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
|
||||
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 {
|
||||
String(decoding: bytes, as: UTF8.self)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,11 @@ public struct CapturedResult: Sendable, Equatable {
|
||||
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||
/// - `exit` is emitted exactly once per child, and only after both
|
||||
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||
/// exits or kills.
|
||||
/// - `kill` drops the stdin handle so writers fail fast.
|
||||
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
|
||||
/// - `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 static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||
@@ -92,12 +95,18 @@ public actor ProcessManager {
|
||||
/// pipes have also reached EOF.
|
||||
var pendingExitCode: Int32?
|
||||
var finalized = false
|
||||
/// Watchdog that forces finalization if EOFs never arrive.
|
||||
var finalizeTask: Task<Void, Never>?
|
||||
}
|
||||
|
||||
private var children: [String: RunningChild] = [:]
|
||||
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||
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.
|
||||
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||
|
||||
@@ -105,6 +114,14 @@ public actor ProcessManager {
|
||||
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)
|
||||
|
||||
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||
@@ -119,39 +136,26 @@ public actor ProcessManager {
|
||||
) throws {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
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 prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: true
|
||||
)
|
||||
let process = prepared.process
|
||||
|
||||
logSpawn(id: id, binary: binary, arguments: arguments, captured: false)
|
||||
|
||||
children[id] = RunningChild(
|
||||
process: process,
|
||||
stdin: stdinPipe.fileHandleForWriting,
|
||||
stdin: prepared.stdinPipe?.fileHandleForWriting,
|
||||
stdoutDecoder: ProcessLineDecoder(),
|
||||
stderrDecoder: ProcessLineDecoder()
|
||||
)
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} 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
|
||||
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = prepared.stderrPipe.fileHandleForReading
|
||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard let self else { return }
|
||||
@@ -163,9 +167,22 @@ public actor ProcessManager {
|
||||
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 }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,55 +201,130 @@ public actor ProcessManager {
|
||||
) async throws -> CapturedResult {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
let prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: false
|
||||
)
|
||||
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
|
||||
|
||||
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 {
|
||||
try process.run()
|
||||
} catch {
|
||||
_ = box.resume(with: -1)
|
||||
captured.removeValue(forKey: id)
|
||||
preKillHooks.removeValue(forKey: id)
|
||||
emit(.error(id: id, message: error.localizedDescription))
|
||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||
}
|
||||
startWaitUntilExitWatchdog(capturedProcess) { status in
|
||||
_ = box.resume(with: status)
|
||||
}
|
||||
|
||||
async let outData = Task.detached {
|
||||
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
async let errData = Task.detached {
|
||||
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
// 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()
|
||||
|
||||
let code = await withCheckedContinuation { continuation in
|
||||
process.terminationHandler = { proc in
|
||||
continuation.resume(returning: proc.terminationStatus)
|
||||
return await withTaskCancellationHandler {
|
||||
async let outData = Task.detached {
|
||||
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
async let errData = Task.detached {
|
||||
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
|
||||
let code = await withCheckedContinuation { continuation in
|
||||
if let status = box.store(continuation) {
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
|
||||
let (out, err) = await (outData, errData)
|
||||
|
||||
// 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))
|
||||
|
||||
return CapturedResult(
|
||||
stdout: String(decoding: out, as: UTF8.self),
|
||||
stderr: String(decoding: err, as: UTF8.self),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
let (out, err) = await (outData, errData)
|
||||
// If kill() already reaped this child, its exit event went out.
|
||||
if captured.removeValue(forKey: id) != nil {
|
||||
emit(.exit(id: id, code: code))
|
||||
}
|
||||
|
||||
return CapturedResult(
|
||||
stdout: String(decoding: out, as: UTF8.self),
|
||||
stderr: String(decoding: err, as: UTF8.self),
|
||||
exitCode: code
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - stdin
|
||||
@@ -255,41 +347,166 @@ public actor ProcessManager {
|
||||
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
|
||||
|
||||
/// Terminates a child. The `exit` event still fires exactly once.
|
||||
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
||||
public func kill(id: String) {
|
||||
/// Terminates a child. First runs any registered pre-kill hook, then
|
||||
/// drops stdin and signals the process. For streaming children the
|
||||
/// `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] {
|
||||
try? child.stdin?.close()
|
||||
child.stdin = nil
|
||||
children[id] = child
|
||||
|
||||
if child.process.isRunning {
|
||||
child.process.terminate()
|
||||
} else {
|
||||
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
||||
} else if child.pendingExitCode == nil {
|
||||
// The process already exited but `didTerminate` has not
|
||||
// run; synthesize it so `maybeFinalize` can fire.
|
||||
didTerminate(id: id, code: child.process.terminationStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let process = captured[id] {
|
||||
if process.isRunning { process.terminate() }
|
||||
if captured.removeValue(forKey: id) != nil {
|
||||
emit(.exit(id: id, code: process.terminationStatus))
|
||||
}
|
||||
// Do not emit `.exit` here; `runCaptured` emits the real code
|
||||
// after the process reaps.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminates every running child; returns how many were signaled
|
||||
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||
@discardableResult
|
||||
public func killAll() -> Int {
|
||||
let ids = Array(children.keys) + Array(captured.keys)
|
||||
for id in ids { kill(id: id) }
|
||||
public func killAll() async -> Int {
|
||||
let ids = runningIDs
|
||||
for id in ids { await kill(id: id) }
|
||||
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
|
||||
|
||||
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] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||
@@ -321,15 +538,14 @@ public actor ProcessManager {
|
||||
|
||||
let log = AppLogger(category: "subprocess")
|
||||
for line in lines {
|
||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else if isStderr {
|
||||
if !isStderr {
|
||||
emitStdoutLine(id: id, line: line)
|
||||
if !line.hasPrefix(Self.rowColorsPrefix) {
|
||||
log.info("[\(id)] \(line)")
|
||||
}
|
||||
} else {
|
||||
log.warn("[\(id)] \(line)")
|
||||
emit(.stderr(id: id, line: line))
|
||||
} else {
|
||||
log.info("[\(id)] \(line)")
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +555,16 @@ public actor ProcessManager {
|
||||
child.pendingExitCode = code
|
||||
try? child.stdin?.close()
|
||||
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
|
||||
maybeFinalize(id: id)
|
||||
}
|
||||
@@ -351,17 +577,17 @@ public actor ProcessManager {
|
||||
child.stdoutEOF, child.stderrEOF,
|
||||
!child.finalized
|
||||
else { return }
|
||||
|
||||
child.finalized = true
|
||||
child.finalizeTask?.cancel()
|
||||
child.finalizeTask = nil
|
||||
children.removeValue(forKey: id)
|
||||
preKillHooks.removeValue(forKey: id)
|
||||
|
||||
// Flush unterminated tail lines.
|
||||
if var decoder = Optional(child.stdoutDecoder),
|
||||
let tail = decoder.finish() {
|
||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: tail))
|
||||
}
|
||||
emitStdoutLine(id: id, line: tail)
|
||||
}
|
||||
if var decoder = Optional(child.stderrDecoder),
|
||||
let tail = decoder.finish() {
|
||||
@@ -369,4 +595,20 @@ public actor ProcessManager {
|
||||
}
|
||||
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.
|
||||
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(preset)
|
||||
return try JSONEncoder.icceryPretty().encode(preset)
|
||||
}
|
||||
|
||||
/// 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`
|
||||
/// (issue #5 — the v1 path is never read).
|
||||
///
|
||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt
|
||||
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// reclassify swatches.
|
||||
public final class SettingsStore: Sendable {
|
||||
|
||||
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
|
||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<AppSettings>
|
||||
|
||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> AppSettings {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||
else {
|
||||
return .default
|
||||
}
|
||||
return settings
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
/// Validates before persisting — throws `SettingsError` listing
|
||||
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
|
||||
guard errors.isEmpty else {
|
||||
throw SettingsError.validationFailed(errors)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||
try store.save(settings)
|
||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,28 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
public var sessionMode: SessionMode
|
||||
/// May differ from `basename` after a `.ti3` import (#94).
|
||||
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(
|
||||
currentStage: Int = WizardStage.generate.rawValue,
|
||||
@@ -28,7 +50,8 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
cwd: String = "",
|
||||
printerName: String? = nil,
|
||||
sessionMode: SessionMode = .profile,
|
||||
profileBasename: String? = nil
|
||||
profileBasename: String? = nil,
|
||||
calibrationOriginalBasename: String = ""
|
||||
) {
|
||||
self.currentStage = currentStage
|
||||
self.basename = basename
|
||||
@@ -36,6 +59,7 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
self.printerName = printerName
|
||||
self.sessionMode = sessionMode
|
||||
self.profileBasename = profileBasename
|
||||
self.calibrationOriginalBasename = calibrationOriginalBasename
|
||||
}
|
||||
|
||||
public static let `default` = WizardState()
|
||||
@@ -50,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||
public final class WizardStateStore: Sendable {
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<WizardState>
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> WizardState {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||
else { return .default }
|
||||
return state
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
public func save(_ state: WizardState) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||
try store.save(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,323 @@
|
||||
# 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.0 |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||
| CI | Gitea Actions `macos-12` runner |
|
||||
| Default branch | `develop` |
|
||||
| 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) — in flight on `milestone/m11-print-settings` |
|
||||
| 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`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
|
||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
|
||||
must not replace the input profile on cancel or non-zero exit.
|
||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
|
||||
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
|
||||
`@Observable`.
|
||||
- **Print.** Unmanaged `lp` with ColorSync suppression
|
||||
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
|
||||
options win over derived CUPS keys. Never `lp -o raw`.
|
||||
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
||||
limit. Split large `VStack`/`Group` trees (#146).
|
||||
|
||||
## Tests
|
||||
|
||||
```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 — currently
|
||||
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and
|
||||
deleted). Do not open umbrella “bugfix” branches that mix tickets.
|
||||
|
||||
`main` is push-protected and requires status check
|
||||
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
|
||||
block PR merges that touch matching paths — do not set that field to `*`.
|
||||
|
||||
## Licence
|
||||
|
||||
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
|
||||
# Mock script for chartread -u
|
||||
# This script simulates the behaviour of chartread for testing purposes.
|
||||
# Mock chartread for bundled/manual testing.
|
||||
# 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
|
||||
IS_XY=0
|
||||
# Basename is the last non-flag argument.
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--xy" ]; then
|
||||
IS_XY=1
|
||||
break
|
||||
fi
|
||||
case "$arg" in
|
||||
-*) ;;
|
||||
*) BASENAME="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read -r _calib
|
||||
echo "Calibration successful."
|
||||
read_input() {
|
||||
IFS= read -r line || return 1
|
||||
}
|
||||
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read -r _sheet1
|
||||
emit_row() {
|
||||
printf 'ROW_COLORS_JSON: %s\n' "$1"
|
||||
}
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid1
|
||||
write_ti3() {
|
||||
if [ -n "$BASENAME" ]; then
|
||||
echo "MOCK_TI3" > "${BASENAME}.ti3"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "locate patch B24 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid2
|
||||
if [ "$MODE" = "xy" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read_input
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
sleep 0.5
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read_input
|
||||
|
||||
# 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
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read_input
|
||||
|
||||
# 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 "Reading sheet 1..."
|
||||
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]}}]}'
|
||||
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
echo "Please remove last sheet from table"
|
||||
exit 0
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
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
|
||||
fi
|
||||
|
||||
# Handheld / strip reader simulation
|
||||
# Handheld / strip mode (default)
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
sleep 1
|
||||
read_input
|
||||
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..."
|
||||
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
|
||||
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
|
||||
|
||||
echo "Hit [Space] to read strip B (or 's' to skip)."
|
||||
sleep 1
|
||||
echo "Hit [Space] to read strip B"
|
||||
read_input
|
||||
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
|
||||
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], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||
EOF
|
||||
|
||||
echo "Ready to read... done."
|
||||
echo "'d' if/when done"
|
||||
while read_input; do
|
||||
case "$line" in
|
||||
d*) write_ti3; exit 0 ;;
|
||||
q*) exit 0 ;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"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 runner: ArgyllRunner
|
||||
let cupsService: CupsService
|
||||
let historyStore: VerificationHistoryStore
|
||||
let mediaStore: MediaLibraryStore
|
||||
let recentProjectsStore: RecentProjectsStore
|
||||
|
||||
static func live(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
@@ -19,11 +22,15 @@ struct AppEnvironment: Sendable {
|
||||
let settingsStore = SettingsStore()
|
||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
var bundledRoot = AppPaths.bundledArgyllDir
|
||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||
#if DEBUG
|
||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||
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 {
|
||||
cupsDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
@@ -34,11 +41,15 @@ struct AppEnvironment: Sendable {
|
||||
presetStore: PresetStore(settingsStore: settingsStore),
|
||||
runner: ArgyllRunner(
|
||||
processManager: .shared,
|
||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
||||
binaryResolver: BinaryResolver(
|
||||
bundledRoot: bundledRoot, overrideDir: overrideDir)
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: .shared,
|
||||
binaryDir: cupsDir)
|
||||
binaryDir: cupsDir),
|
||||
historyStore: VerificationHistoryStore(),
|
||||
mediaStore: MediaLibraryStore(),
|
||||
recentProjectsStore: RecentProjectsStore()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,10 +77,32 @@ enum UITestHooks {
|
||||
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
||||
/// `select_directory` result (working-directory browse).
|
||||
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.
|
||||
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||
/// Preset export destination.
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,8 @@ final class FileDialogService {
|
||||
|
||||
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "target.ti1"
|
||||
panel.allowedContentTypes = utTypes(["ti1"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Choose the .ti1 target file to create"
|
||||
return run(panel)
|
||||
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
|
||||
message: "Choose the .ti1 target file to create")
|
||||
}
|
||||
|
||||
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||
@@ -66,12 +61,7 @@ final class FileDialogService {
|
||||
|
||||
/// `selectCsvSavePath` — verification-history CSV export.
|
||||
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "verification-history.csv"
|
||||
panel.allowedContentTypes = utTypes(["csv"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
return run(panel)
|
||||
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
|
||||
}
|
||||
|
||||
/// `selectCalFile` — `.cal` calibration curves.
|
||||
@@ -80,6 +70,19 @@ final class FileDialogService {
|
||||
message: "Choose a calibration file (.cal)")
|
||||
}
|
||||
|
||||
/// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147).
|
||||
func selectGamutFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["gam"], startingAt: start,
|
||||
message: "Choose a .gam surface mesh",
|
||||
allowsOtherFileTypes: false)
|
||||
}
|
||||
|
||||
/// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147).
|
||||
func selectTiffFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["tif", "tiff"], startingAt: start,
|
||||
message: "Choose a target TIFF page")
|
||||
}
|
||||
|
||||
/// `btnImportPreset` — open a `.json` preset file.
|
||||
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["json"], startingAt: start,
|
||||
@@ -88,30 +91,60 @@ final class FileDialogService {
|
||||
|
||||
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(name).json"
|
||||
panel.allowedContentTypes = utTypes(["json"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Export this preset as JSON"
|
||||
return run(panel)
|
||||
save(named: "\(name).json", extensions: ["json"], startingAt: start,
|
||||
message: "Export this preset as JSON")
|
||||
}
|
||||
|
||||
/// `selectProjectFile` — open `.icceryproj` only (issue #149).
|
||||
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
|
||||
open(extensions: ["icceryproj"], startingAt: start,
|
||||
message: "Open ICCery Project",
|
||||
title: "Open ICCery Project",
|
||||
allowsOtherFileTypes: false)
|
||||
}
|
||||
|
||||
/// `selectProjectSavePath` — save `.icceryproj`, suggested name
|
||||
/// `{basename}.icceryproj` in the working folder (issue #149).
|
||||
func selectProjectSavePath(
|
||||
basename: String, startingAt start: URL? = nil
|
||||
) -> URL? {
|
||||
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
|
||||
startingAt: start, message: "Save ICCery Project")
|
||||
}
|
||||
|
||||
// MARK: - Internals (private — not a shared public picker API)
|
||||
|
||||
private func save(
|
||||
named: String,
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
message: String? = nil
|
||||
) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = named
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
private func open(
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
message: String?
|
||||
message: String?,
|
||||
title: String? = nil,
|
||||
allowsOtherFileTypes: Bool = true
|
||||
) -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.allowsOtherFileTypes = allowsOtherFileTypes
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
if let title { panel.title = title }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
import SwiftUI
|
||||
import SceneKit
|
||||
import Metal
|
||||
import ICCeryCore
|
||||
import simd
|
||||
|
||||
/// Builds a SceneKit geometry from a `GamutMesh` while dropping faces whose
|
||||
/// indices are not backed by a vertex in the source mesh.
|
||||
@MainActor
|
||||
internal struct GamutSceneGeometryBuilder {
|
||||
static func geometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
|
||||
let positions = mesh.vertices.map { $0.position }
|
||||
let positionData = positions.withUnsafeBytes { Data($0) }
|
||||
let positionSource = SCNGeometrySource(
|
||||
data: positionData,
|
||||
semantic: .vertex,
|
||||
vectorCount: positions.count,
|
||||
usesFloatComponents: true,
|
||||
componentsPerVector: 3,
|
||||
bytesPerComponent: MemoryLayout<Float>.size,
|
||||
dataOffset: 0,
|
||||
dataStride: MemoryLayout<SIMD3<Float>>.stride
|
||||
)
|
||||
|
||||
let colors: [SIMD4<Float>] = mesh.vertices.map { v in
|
||||
SIMD4<Float>(Float(v.rgb.r), Float(v.rgb.g), Float(v.rgb.b), 1.0)
|
||||
}
|
||||
let colorData = colors.withUnsafeBytes { Data($0) }
|
||||
let colorSource = SCNGeometrySource(
|
||||
data: colorData,
|
||||
semantic: .color,
|
||||
vectorCount: colors.count,
|
||||
usesFloatComponents: true,
|
||||
componentsPerVector: 4,
|
||||
bytesPerComponent: MemoryLayout<Float>.size,
|
||||
dataOffset: 0,
|
||||
dataStride: MemoryLayout<SIMD4<Float>>.stride
|
||||
)
|
||||
|
||||
let vcount = mesh.vertices.count
|
||||
var indices: [UInt32] = []
|
||||
var validFaces: [GamutTriangle] = []
|
||||
indices.reserveCapacity(mesh.faces.count * 3)
|
||||
for face in mesh.faces {
|
||||
guard Int(face.a) < vcount, Int(face.b) < vcount, Int(face.c) < vcount else {
|
||||
continue
|
||||
}
|
||||
indices.append(face.a)
|
||||
indices.append(face.b)
|
||||
indices.append(face.c)
|
||||
validFaces.append(face)
|
||||
}
|
||||
let data = indices.withUnsafeBytes { Data($0) }
|
||||
let element = SCNGeometryElement(
|
||||
data: data,
|
||||
primitiveType: .triangles,
|
||||
primitiveCount: validFaces.count,
|
||||
bytesPerIndex: 4
|
||||
)
|
||||
|
||||
let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element])
|
||||
return (geometry, element)
|
||||
}
|
||||
}
|
||||
|
||||
/// Native SceneKit 3D gamut viewer (issues #28, #147).
|
||||
///
|
||||
/// Displays the bundled `sRGB.gam` reference plus up to two profile
|
||||
/// meshes with independent visibility toggles, a status line, and an
|
||||
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
|
||||
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
|
||||
/// `z = b*`.
|
||||
struct GamutView: View {
|
||||
@StateObject private var viewModel: GamutViewModel
|
||||
@State private var pause: () -> Void = {}
|
||||
@FocusState private var isFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var showingAllHelp: Bool
|
||||
|
||||
init(
|
||||
environment: AppEnvironment,
|
||||
profileGamURL: URL? = nil,
|
||||
showingAllHelp: Binding<Bool>
|
||||
) {
|
||||
_viewModel = StateObject(wrappedValue: GamutViewModel(
|
||||
environment: environment, profileGamURL: profileGamURL))
|
||||
_showingAllHelp = showingAllHelp
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider().overlay(Theme.border)
|
||||
sceneArea
|
||||
Divider().overlay(Theme.border)
|
||||
statusLine
|
||||
inspectPanel
|
||||
Divider().overlay(Theme.border)
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.background(Theme.background)
|
||||
.onDisappear { pause() }
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("gamutView")
|
||||
.sheet(isPresented: $viewModel.showingTiffPreview) {
|
||||
tiffPreviewSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 12) {
|
||||
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
|
||||
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
|
||||
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
|
||||
Spacer()
|
||||
addCompareMenu
|
||||
Button("Remove compare") { viewModel.removeCompare() }
|
||||
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
|
||||
.accessibilityIdentifier("btnGamutRemoveCompare")
|
||||
Button("Sample TIFF…") { viewModel.openTiffSample() }
|
||||
.accessibilityIdentifier("btnGamutSampleTiff")
|
||||
.helpOverlay(
|
||||
"Sample a colour from a target TIFF page.",
|
||||
showing: $showingAllHelp)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func layerToggle(id: String, fallback: String) -> some View {
|
||||
let layer = viewModel.layer(id: id)
|
||||
return Toggle(isOn: Binding(
|
||||
get: { layer != nil && viewModel.visibleIDs.contains(id) },
|
||||
set: { on in
|
||||
if on {
|
||||
viewModel.visibleIDs.insert(id)
|
||||
} else {
|
||||
viewModel.visibleIDs.remove(id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text(layer?.displayName ?? fallback)
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(layer == nil)
|
||||
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
|
||||
// macOS 12 puts the identifier on the Toggle's container, an
|
||||
// element that never reports isEnabled — combine so the a11y
|
||||
// leaf is the checkbox itself.
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("gamutLayer-\(id)")
|
||||
}
|
||||
|
||||
private var addCompareMenu: some View {
|
||||
Menu("Add compare…") {
|
||||
Button("Open .gam…") { viewModel.openCompareGam() }
|
||||
.accessibilityIdentifier("btnGamutOpenGam")
|
||||
Button("Open profile…") { viewModel.openCompareProfile() }
|
||||
.accessibilityIdentifier("btnGamutOpenProfile")
|
||||
}
|
||||
.accessibilityIdentifier("btnGamutAddCompare")
|
||||
.helpOverlay(
|
||||
"Add a second profile or .gam mesh to compare against.",
|
||||
showing: $showingAllHelp)
|
||||
}
|
||||
|
||||
// MARK: - Scene
|
||||
|
||||
private var sceneArea: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if viewModel.viewerUnavailable {
|
||||
Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.accessibilityIdentifier("gamutViewerUnavailable")
|
||||
} else {
|
||||
GamutSceneView(
|
||||
layers: viewModel.layers,
|
||||
visibleIDs: viewModel.visibleIDs,
|
||||
onReset: $viewModel.resetCamera,
|
||||
onPause: $pause,
|
||||
onUnavailable: { viewModel.viewerUnavailable = true },
|
||||
onInspect: { point, layerID in
|
||||
if let layerID {
|
||||
viewModel.inspectSceneHit(world: point, layerID: layerID)
|
||||
} else {
|
||||
viewModel.clearInspect()
|
||||
}
|
||||
}
|
||||
)
|
||||
.focusable()
|
||||
.focused($isFocused)
|
||||
.onAppear { isFocused = true }
|
||||
}
|
||||
Button(action: { viewModel.resetCamera() }) {
|
||||
Text("Reset view")
|
||||
}
|
||||
.accessibilityIdentifier("btnResetGamutCamera")
|
||||
.padding(8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
private var statusLine: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(viewModel.status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("gamutStatusText")
|
||||
if let notice = viewModel.noticeText {
|
||||
Text(notice)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("gamutNoticeText")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - Inspect panel
|
||||
|
||||
private var inspectPanel: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let lab = viewModel.inspectLab {
|
||||
inspectSwatch
|
||||
labReadout(lab)
|
||||
containmentColumn
|
||||
if viewModel.inspectIsApproximate {
|
||||
Text("approx. Lab, not ColorSync")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("gamutInspectApprox")
|
||||
}
|
||||
} else {
|
||||
Text("Click the mesh, or enter Lab, to inspect.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("gamutInspectIdle")
|
||||
}
|
||||
Spacer()
|
||||
labEntryFields
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 56)
|
||||
.background(Theme.panel)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("gamutInspectPanel")
|
||||
.helpOverlay(
|
||||
"Inspect a Lab point against each loaded gamut.",
|
||||
showing: $showingAllHelp)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var inspectSwatch: some View {
|
||||
if let swatch = viewModel.inspectSwatch {
|
||||
Color(red: swatch.r, green: swatch.g, blue: swatch.b)
|
||||
.frame(width: 16, height: 16)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
.overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border))
|
||||
.accessibilityIdentifier("gamutInspectSwatch")
|
||||
}
|
||||
}
|
||||
|
||||
private func labReadout(_ lab: LabColor) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(String(format: "L %.1f", lab.l))
|
||||
.accessibilityIdentifier("gamutInspectL")
|
||||
Text(String(format: "a %.1f", lab.a))
|
||||
.accessibilityIdentifier("gamutInspectA")
|
||||
Text(String(format: "b %.1f", lab.b))
|
||||
.accessibilityIdentifier("gamutInspectB")
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
|
||||
private var containmentColumn: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(viewModel.inspectResults, id: \.id) { result in
|
||||
Text("\(result.name) \(containmentWord(result.containment))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("gamutInspect-\(result.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func containmentWord(_ containment: GamutContainment) -> String {
|
||||
switch containment {
|
||||
case .inside: return "in"
|
||||
case .outside: return "out"
|
||||
case .unknown: return "?"
|
||||
}
|
||||
}
|
||||
|
||||
private var labEntryFields: some View {
|
||||
HStack(spacing: 6) {
|
||||
Text("Lab 0–100 · ±128")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("L", text: $viewModel.labEntryL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryL")
|
||||
TextField("a", text: $viewModel.labEntryA)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryA")
|
||||
TextField("b", text: $viewModel.labEntryB)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 56)
|
||||
.accessibilityIdentifier("gamutLabEntryB")
|
||||
Button("Inspect") { viewModel.inspectEnteredLab() }
|
||||
.disabled(!viewModel.canInspectLab)
|
||||
.accessibilityIdentifier("btnGamutInspectLab")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
/// Always-visible Close (#147) — the fallback banner keeps it
|
||||
/// reachable and Escape works via `.cancelAction` without SceneKit.
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("btnCloseGamut")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - TIFF sample sheet
|
||||
|
||||
private var tiffPreviewSheet: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Click a pixel to sample its colour.")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
if let png = viewModel.tiffPreviewPNG {
|
||||
TiffSampleImageView(pngData: png) { r, g, b in
|
||||
viewModel.sampleTiffPixel(r: r, g: g, b: b)
|
||||
}
|
||||
.frame(minWidth: 320, minHeight: 240)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { viewModel.showingTiffPreview = false }
|
||||
.accessibilityIdentifier("btnCloseGamutTiffPreview")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("gamutTiffPreview")
|
||||
}
|
||||
}
|
||||
|
||||
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
|
||||
/// per ``NamedGamut`` layer.
|
||||
///
|
||||
/// Layer toggles hide/show `SCNNode`s — the scene is built once and the
|
||||
/// camera is only reset through the explicit reset path, never on a
|
||||
/// mesh or visibility update.
|
||||
private struct GamutSceneView: NSViewRepresentable {
|
||||
var layers: [NamedGamut]
|
||||
var visibleIDs: Set<String>
|
||||
var onReset: Binding<() -> Void>
|
||||
var onPause: Binding<() -> Void>
|
||||
var onUnavailable: () -> Void
|
||||
var onInspect: (SIMD3<Float>, String?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> SCNView {
|
||||
let scnView = SCNView()
|
||||
scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1)
|
||||
scnView.allowsCameraControl = true
|
||||
scnView.showsStatistics = false
|
||||
scnView.antialiasingMode = .multisampling4X
|
||||
|
||||
let scene = SCNScene()
|
||||
scnView.scene = scene
|
||||
scnView.autoenablesDefaultLighting = false
|
||||
|
||||
context.coordinator.scnView = scnView
|
||||
context.coordinator.scene = scene
|
||||
context.coordinator.onInspect = onInspect
|
||||
context.coordinator.buildSceneOnce()
|
||||
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||
context.coordinator.installKeyMonitor()
|
||||
context.coordinator.installClickGesture()
|
||||
|
||||
// Safety net only — the primary no-Metal check is
|
||||
// `GamutSceneAvailability.isAvailable`, evaluated before this
|
||||
// view is mounted. Never respawn the view in a loop.
|
||||
if MTLCreateSystemDefaultDevice() == nil {
|
||||
DispatchQueue.main.async { onUnavailable() }
|
||||
}
|
||||
|
||||
return scnView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SCNView, context: Context) {
|
||||
context.coordinator.onInspect = onInspect
|
||||
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
let coordinator = Coordinator()
|
||||
onReset.wrappedValue = { [weak coordinator] in
|
||||
coordinator?.resetCamera()
|
||||
}
|
||||
onPause.wrappedValue = { [weak coordinator] in
|
||||
coordinator?.pause()
|
||||
}
|
||||
return coordinator
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||
coordinator.removeKeyMonitor()
|
||||
if let click = coordinator.clickGesture {
|
||||
nsView.removeGestureRecognizer(click)
|
||||
}
|
||||
nsView.isPlaying = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject {
|
||||
weak var scnView: SCNView?
|
||||
weak var scene: SCNScene?
|
||||
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
|
||||
private(set) var clickGesture: NSClickGestureRecognizer?
|
||||
private var keyMonitor: Any?
|
||||
|
||||
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
|
||||
private var layerNodes: [String: SCNNode] = [:]
|
||||
private let axisNode = SCNNode()
|
||||
private let layerGroup = SCNNode()
|
||||
private let cameraNode: SCNNode = {
|
||||
let node = SCNNode()
|
||||
node.camera = SCNCamera()
|
||||
node.camera?.zFar = 2000
|
||||
return node
|
||||
}()
|
||||
|
||||
/// Builds the static scene furniture exactly once — axis
|
||||
/// scaffold, lights, camera home. Layer content lives under
|
||||
/// `layerGroup` and is managed by `syncLayers`.
|
||||
func buildSceneOnce() {
|
||||
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
|
||||
|
||||
scene.rootNode.addChildNode(axisNode)
|
||||
scene.rootNode.addChildNode(layerGroup)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
buildAxisScaffold()
|
||||
addLights(to: scene)
|
||||
resetCamera()
|
||||
}
|
||||
|
||||
/// Reconciles the node set with `layers` and `visibleIDs`.
|
||||
///
|
||||
/// New layers get a node; removed layers lose theirs; hidden
|
||||
/// layers keep their mesh (`isHidden` only). Never rebuilds the
|
||||
/// scene, so the camera is untouched by a checkbox toggle.
|
||||
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<String>) {
|
||||
guard scene != nil else { return }
|
||||
let wanted = Set(layers.map { $0.id })
|
||||
for (id, node) in layerNodes where !wanted.contains(id) {
|
||||
node.removeFromParentNode()
|
||||
layerNodes.removeValue(forKey: id)
|
||||
}
|
||||
for layer in layers {
|
||||
if layerNodes[layer.id] == nil {
|
||||
let node = makeLayerNode(for: layer)
|
||||
layerNodes[layer.id] = node
|
||||
layerGroup.addChildNode(node)
|
||||
}
|
||||
layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLayerNode(for layer: NamedGamut) -> SCNNode {
|
||||
let node: SCNNode
|
||||
switch layer.role {
|
||||
case .reference:
|
||||
node = referenceMeshNode(layer.mesh)
|
||||
case .profileA:
|
||||
node = profileMeshNode(layer.mesh)
|
||||
case .profileB:
|
||||
node = compareMeshNode(layer.mesh)
|
||||
}
|
||||
node.name = layer.id
|
||||
return node
|
||||
}
|
||||
|
||||
// MARK: - Click inspect (#147)
|
||||
|
||||
/// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer`
|
||||
/// only fires on a press+release in place, so orbit drags are
|
||||
/// untouched.
|
||||
func installClickGesture() {
|
||||
guard let scnView, clickGesture == nil else { return }
|
||||
let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:)))
|
||||
scnView.addGestureRecognizer(gesture)
|
||||
clickGesture = gesture
|
||||
}
|
||||
|
||||
@objc private func handleClick(_ gesture: NSClickGestureRecognizer) {
|
||||
guard let scnView else { return }
|
||||
let point = gesture.location(in: scnView)
|
||||
for hit in scnView.hitTest(point, options: nil) {
|
||||
if let layerID = layerID(for: hit.node) {
|
||||
let world = hit.worldCoordinates
|
||||
onInspect(
|
||||
SIMD3<Float>(Float(world.x), Float(world.y), Float(world.z)),
|
||||
layerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Axis scaffold / empty background → back to idle.
|
||||
onInspect(.zero, nil)
|
||||
}
|
||||
|
||||
/// Walks the hit node's ancestor chain looking for a layer node.
|
||||
private func layerID(for node: SCNNode) -> String? {
|
||||
var current: SCNNode? = node
|
||||
while let node = current {
|
||||
if let name = node.name, layerNodes[name] != nil { return name }
|
||||
current = node.parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Scene furniture (unchanged from #28)
|
||||
|
||||
private func addLights(to scene: SCNScene) {
|
||||
let ambient = SCNNode()
|
||||
ambient.light = SCNLight()
|
||||
ambient.light?.type = .ambient
|
||||
ambient.light?.color = NSColor.white
|
||||
ambient.light?.intensity = 750
|
||||
scene.rootNode.addChildNode(ambient)
|
||||
|
||||
let key = SCNNode()
|
||||
key.light = SCNLight()
|
||||
key.light?.type = .directional
|
||||
key.light?.color = NSColor.white
|
||||
key.light?.intensity = 800
|
||||
key.position = SCNVector3(150, 250, 150)
|
||||
key.look(at: SCNVector3(0, 50, 0))
|
||||
scene.rootNode.addChildNode(key)
|
||||
|
||||
let fill = SCNNode()
|
||||
fill.light = SCNLight()
|
||||
fill.light?.type = .directional
|
||||
fill.light?.color = NSColor.white
|
||||
fill.light?.intensity = 350
|
||||
fill.position = SCNVector3(-120, -80, -120)
|
||||
fill.look(at: SCNVector3(0, 50, 0))
|
||||
scene.rootNode.addChildNode(fill)
|
||||
}
|
||||
|
||||
private func buildAxisScaffold() {
|
||||
axisNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
// Bounding box: a*,b* ±128, L* 0–100.
|
||||
let box = buildWireBox(size: SIMD3<Float>(256, 100, 256), color: NSColor(red: 0.137, green: 0.137, blue: 0.212, alpha: 0.9))
|
||||
box.position = SCNVector3(0, 50, 0)
|
||||
axisNode.addChildNode(box)
|
||||
|
||||
// Ground grid at y=0.
|
||||
axisNode.addChildNode(buildGridNode())
|
||||
|
||||
// Axis lines.
|
||||
axisNode.addChildNode(buildLineNode(
|
||||
from: SIMD3<Float>(0, 0, 0),
|
||||
to: SIMD3<Float>(0, 100, 0),
|
||||
color: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0)
|
||||
))
|
||||
let abAxisColor = NSColor(red: 0.6, green: 0.733, blue: 0.8, alpha: 1.0)
|
||||
axisNode.addChildNode(buildLineNode(
|
||||
from: SIMD3<Float>(-128, 0, 0),
|
||||
to: SIMD3<Float>(128, 0, 0),
|
||||
color: abAxisColor
|
||||
))
|
||||
axisNode.addChildNode(buildLineNode(
|
||||
from: SIMD3<Float>(0, 0, -128),
|
||||
to: SIMD3<Float>(0, 0, 128),
|
||||
color: abAxisColor
|
||||
))
|
||||
}
|
||||
|
||||
private func buildWireBox(size: SIMD3<Float>, color: NSColor) -> SCNNode {
|
||||
let hx = size.x / 2
|
||||
let hy = size.y / 2
|
||||
let hz = size.z / 2
|
||||
|
||||
let corners: [SIMD3<Float>] = [
|
||||
SIMD3(-hx, -hy, -hz), SIMD3(hx, -hy, -hz),
|
||||
SIMD3(hx, -hy, hz), SIMD3(-hx, -hy, hz),
|
||||
SIMD3(-hx, hy, -hz), SIMD3(hx, hy, -hz),
|
||||
SIMD3(hx, hy, hz), SIMD3(-hx, hy, hz),
|
||||
]
|
||||
|
||||
// 12 edges, two vertices each.
|
||||
let edges: [(Int, Int)] = [
|
||||
(0,1), (1,2), (2,3), (3,0),
|
||||
(4,5), (5,6), (6,7), (7,4),
|
||||
(0,4), (1,5), (2,6), (3,7),
|
||||
]
|
||||
|
||||
var points: [SIMD3<Float>] = []
|
||||
for (a, b) in edges {
|
||||
points.append(corners[a])
|
||||
points.append(corners[b])
|
||||
}
|
||||
|
||||
return lineNode(points: points, color: color)
|
||||
}
|
||||
|
||||
private func buildGridNode() -> SCNNode {
|
||||
let divisions = 16
|
||||
let half = Float(128)
|
||||
let step = (half * 2) / Float(divisions)
|
||||
|
||||
var points: [SIMD3<Float>] = []
|
||||
for i in 0...divisions {
|
||||
let v = -half + step * Float(i)
|
||||
// X-aligned
|
||||
points.append(SIMD3(-half, 0, v))
|
||||
points.append(SIMD3(half, 0, v))
|
||||
// Z-aligned
|
||||
points.append(SIMD3(v, 0, -half))
|
||||
points.append(SIMD3(v, 0, half))
|
||||
}
|
||||
|
||||
let gridColor = NSColor(red: 0.118, green: 0.118, blue: 0.157, alpha: 1.0)
|
||||
return lineNode(points: points, color: gridColor)
|
||||
}
|
||||
|
||||
private func buildLineNode(from: SIMD3<Float>, to: SIMD3<Float>, color: NSColor) -> SCNNode {
|
||||
return lineNode(points: [from, to], color: color)
|
||||
}
|
||||
|
||||
/// Builds a line-set from a flat list of point pairs.
|
||||
///
|
||||
/// Uses data-backed `SCNGeometrySource` so it works with `simd` vectors
|
||||
/// and avoids the SceneKit convenience-initializer label mismatch.
|
||||
private func lineNode(points: [SIMD3<Float>], color: NSColor) -> SCNNode {
|
||||
let source = source(for: points)
|
||||
|
||||
let count = points.count
|
||||
var indices: [UInt32] = []
|
||||
indices.reserveCapacity(count)
|
||||
for i in 0..<UInt32(count) {
|
||||
indices.append(i)
|
||||
}
|
||||
let data = indices.withUnsafeBytes { Data($0) }
|
||||
let element = SCNGeometryElement(
|
||||
data: data,
|
||||
primitiveType: .line,
|
||||
primitiveCount: count / 2,
|
||||
bytesPerIndex: 4
|
||||
)
|
||||
|
||||
let geometry = SCNGeometry(sources: [source], elements: [element])
|
||||
let material = SCNMaterial()
|
||||
material.lightingModel = .constant
|
||||
material.diffuse.contents = color
|
||||
material.isDoubleSided = false
|
||||
geometry.materials = [material]
|
||||
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
/// Profile A: solid vertex-coloured surface.
|
||||
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||
let (geometry, _) = scnGeometry(for: mesh)
|
||||
|
||||
let material = SCNMaterial()
|
||||
material.lightingModel = .lambert
|
||||
material.diffuse.contents = NSColor.white
|
||||
material.transparency = 0.88
|
||||
material.isDoubleSided = true
|
||||
geometry.materials = [material]
|
||||
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
/// Compare profile B: same vertex colours at ~30 % opacity so
|
||||
/// overlaps with A and the sRGB reference stay readable.
|
||||
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||
let (geometry, _) = scnGeometry(for: mesh)
|
||||
|
||||
let material = SCNMaterial()
|
||||
material.lightingModel = .lambert
|
||||
material.diffuse.contents = NSColor.white
|
||||
material.transparency = 0.30
|
||||
material.isDoubleSided = true
|
||||
material.writesToDepthBuffer = false
|
||||
geometry.materials = [material]
|
||||
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
/// Bundled sRGB reference: faint fill + structural edge lines.
|
||||
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
|
||||
let (geometry, _) = scnGeometry(for: mesh)
|
||||
|
||||
// Faint fill.
|
||||
let fillMaterial = SCNMaterial()
|
||||
fillMaterial.lightingModel = .lambert
|
||||
fillMaterial.diffuse.contents = NSColor(red: 0.533, green: 0.6, blue: 0.733, alpha: 1.0)
|
||||
fillMaterial.transparency = 0.93
|
||||
fillMaterial.isDoubleSided = true
|
||||
fillMaterial.writesToDepthBuffer = false
|
||||
geometry.materials = [fillMaterial]
|
||||
|
||||
let fillNode = SCNNode(geometry: geometry)
|
||||
|
||||
// Structural outline: one line per triangle edge.
|
||||
var linePoints: [SIMD3<Float>] = []
|
||||
let vcount = mesh.vertices.count
|
||||
for face in mesh.faces
|
||||
where Int(face.a) < vcount && Int(face.b) < vcount && Int(face.c) < vcount {
|
||||
let va = mesh.vertices[Int(face.a)].position
|
||||
let vb = mesh.vertices[Int(face.b)].position
|
||||
let vc = mesh.vertices[Int(face.c)].position
|
||||
linePoints.append(va); linePoints.append(vb)
|
||||
linePoints.append(vb); linePoints.append(vc)
|
||||
linePoints.append(vc); linePoints.append(va)
|
||||
}
|
||||
|
||||
let edgeColor = NSColor(red: 0.4, green: 0.533, blue: 0.667, alpha: 0.55)
|
||||
let edgeNode = lineNode(points: linePoints, color: edgeColor)
|
||||
|
||||
let group = SCNNode()
|
||||
group.addChildNode(fillNode)
|
||||
group.addChildNode(edgeNode)
|
||||
return group
|
||||
}
|
||||
|
||||
/// Returns an `SCNGeometry` with per-vertex positions and sRGB colours.
|
||||
///
|
||||
/// Uses data-backed `SCNGeometrySource` initializers; this is the only
|
||||
/// path that supports vertex colours through the `.color` semantic.
|
||||
private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
|
||||
GamutSceneGeometryBuilder.geometry(for: mesh)
|
||||
}
|
||||
|
||||
/// Shared helper for data-backed position sources.
|
||||
private func source(for points: [SIMD3<Float>]) -> SCNGeometrySource {
|
||||
let data = points.withUnsafeBytes { Data($0) }
|
||||
return SCNGeometrySource(
|
||||
data: data,
|
||||
semantic: .vertex,
|
||||
vectorCount: points.count,
|
||||
usesFloatComponents: true,
|
||||
componentsPerVector: 3,
|
||||
bytesPerComponent: MemoryLayout<Float>.size,
|
||||
dataOffset: 0,
|
||||
dataStride: MemoryLayout<SIMD3<Float>>.stride
|
||||
)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
scnView?.isPlaying = false
|
||||
}
|
||||
|
||||
/// Local key-down monitor for the R camera-reset shortcut (the
|
||||
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
|
||||
/// events aimed at this view's window are handled; everything
|
||||
/// else passes through untouched.
|
||||
func installKeyMonitor() {
|
||||
guard keyMonitor == nil else { return }
|
||||
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
|
||||
[weak self] event in
|
||||
guard let self,
|
||||
let scnView = self.scnView,
|
||||
event.window === scnView.window,
|
||||
event.charactersIgnoringModifiers?.uppercased() == "R"
|
||||
else { return event }
|
||||
self.resetCamera()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeKeyMonitor() {
|
||||
if let keyMonitor {
|
||||
NSEvent.removeMonitor(keyMonitor)
|
||||
self.keyMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetCamera() {
|
||||
guard let scnView else { return }
|
||||
|
||||
// Re-create the camera node so `allowsCameraControl` starts from the
|
||||
// canonical home position every time.
|
||||
let newCameraNode = SCNNode()
|
||||
newCameraNode.camera = SCNCamera()
|
||||
newCameraNode.camera?.zFar = 2000
|
||||
|
||||
let eye = SIMD3<Float>(180, 120, 180)
|
||||
let target = SIMD3<Float>(0, 50, 0)
|
||||
newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3<Float>(0, 1, 0))
|
||||
|
||||
if let scene = scnView.scene, scene.rootNode.childNodes.contains(cameraNode) {
|
||||
cameraNode.removeFromParentNode()
|
||||
}
|
||||
scnView.scene?.rootNode.addChildNode(newCameraNode)
|
||||
scnView.pointOfView = newCameraNode
|
||||
}
|
||||
|
||||
private func lookAt(eye: SIMD3<Float>, target: SIMD3<Float>, up: SIMD3<Float>) -> simd_float4x4 {
|
||||
let forward = normalize(target - eye)
|
||||
let right = normalize(cross(up, forward))
|
||||
let newUp = cross(forward, right)
|
||||
|
||||
var matrix = simd_float4x4()
|
||||
matrix.columns.0 = SIMD4<Float>(right, 0)
|
||||
matrix.columns.1 = SIMD4<Float>(newUp, 0)
|
||||
matrix.columns.2 = SIMD4<Float>(-forward, 0)
|
||||
matrix.columns.3 = SIMD4<Float>(eye, 1)
|
||||
return matrix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Click-to-sample image view for the TIFF preview sheet (#147).
|
||||
///
|
||||
/// The TIFF is already decoded to PNG on the host side (#58); the view
|
||||
/// reports 8-bit sRGB pixel values at the clicked point — the Lab
|
||||
/// conversion is the documented approximate matrix helper, not a CMM.
|
||||
private struct TiffSampleImageView: NSViewRepresentable {
|
||||
let pngData: Data
|
||||
var onSample: (Int, Int, Int) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> TiffSampleNSView {
|
||||
let view = TiffSampleNSView()
|
||||
view.image = NSImage(data: pngData)
|
||||
view.onSample = onSample
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: TiffSampleNSView, context: Context) {
|
||||
nsView.onSample = onSample
|
||||
}
|
||||
}
|
||||
|
||||
private final class TiffSampleNSView: NSView {
|
||||
var image: NSImage? {
|
||||
didSet {
|
||||
bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
.flatMap { NSBitmapImageRep(cgImage: $0) }
|
||||
invalidateIntrinsicContentSize()
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
var onSample: ((Int, Int, Int) -> Void)?
|
||||
private var bitmapRep: NSBitmapImageRep?
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
image?.size ?? NSSize(width: 320, height: 240)
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill()
|
||||
dirtyRect.fill()
|
||||
guard let image else { return }
|
||||
image.draw(in: imageRect())
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
guard let rep = bitmapRep else { return }
|
||||
let rect = imageRect()
|
||||
let location = convert(event.locationInWindow, from: nil)
|
||||
guard rect.contains(location), rect.width > 0, rect.height > 0 else { return }
|
||||
|
||||
let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide))
|
||||
// This view is not flipped: y grows up, bitmap rows grow down.
|
||||
let y = rep.pixelsHigh - 1
|
||||
- Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh))
|
||||
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return }
|
||||
|
||||
guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return }
|
||||
onSample?(
|
||||
Int((color.redComponent * 255).rounded()),
|
||||
Int((color.greenComponent * 255).rounded()),
|
||||
Int((color.blueComponent * 255).rounded()))
|
||||
}
|
||||
|
||||
/// Aspect-fit rect of the image inside `bounds`.
|
||||
private func imageRect() -> NSRect {
|
||||
guard let image, image.size.width > 0, image.size.height > 0 else { return .zero }
|
||||
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
|
||||
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
return NSRect(
|
||||
x: (bounds.width - size.width) / 2,
|
||||
y: (bounds.height - size.height) / 2,
|
||||
width: size.width,
|
||||
height: size.height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import ICCeryCore
|
||||
import Metal
|
||||
import simd
|
||||
|
||||
/// Whether the SceneKit gamut scene can render on this host (#147).
|
||||
///
|
||||
/// Checked **before** `GamutSceneView` is mounted — constructing an
|
||||
/// `SCNView` on a Metal-less machine can wedge the main thread, which
|
||||
/// also stalls app quit behind the open sheet.
|
||||
enum GamutSceneAvailability {
|
||||
static var isAvailable: Bool {
|
||||
if UITestHooks.skipSceneKit { return false }
|
||||
return MTLCreateSystemDefaultDevice() != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// View model for the native SceneKit gamut viewer (issues #28, #147).
|
||||
///
|
||||
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
|
||||
/// profile `.gam` when one exists, and an optional compare mesh the user
|
||||
/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info
|
||||
/// notice, never fatal (#24).
|
||||
@MainActor
|
||||
final class GamutViewModel: ObservableObject {
|
||||
|
||||
/// Stable layer ids — also the `gamutLayer-<id>` a11y suffixes.
|
||||
static let srgbLayerID = "sRGB"
|
||||
static let profileLayerID = "profile"
|
||||
static let compareLayerID = "compare"
|
||||
|
||||
/// Loaded meshes: bundled sRGB plus up to two profiles.
|
||||
@Published var layers: [NamedGamut] = []
|
||||
|
||||
/// Layer ids currently shown in the scene. Toggling never unloads
|
||||
/// the mesh — the `SCNNode` is hidden only.
|
||||
@Published var visibleIDs: Set<String> = [srgbLayerID]
|
||||
|
||||
/// User-facing status line (`gamutStatusText`). Always non-empty
|
||||
/// once set — `Milestone6GamutUITests` asserts it.
|
||||
@Published var status = "Loading gamut…"
|
||||
|
||||
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
|
||||
/// sits behind the sheet, so notices surface here instead.
|
||||
@Published var noticeText: String?
|
||||
|
||||
/// Set when `SCNView` cannot create a render context; the scene is
|
||||
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
|
||||
@Published var viewerUnavailable = false
|
||||
|
||||
/// Closure injected into the SceneKit view to request a camera reset.
|
||||
@Published var resetCamera: () -> Void = {}
|
||||
|
||||
// MARK: - Inspect panel
|
||||
|
||||
/// Lab point currently inspected, or `nil` for the idle state.
|
||||
@Published var inspectLab: LabColor?
|
||||
|
||||
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
|
||||
/// the inspected Lab.
|
||||
@Published var inspectSwatch: DisplayRGB?
|
||||
|
||||
/// `true` when the swatch/Lab came from the approximate helper or a
|
||||
/// typed value — drives the "approx. Lab, not ColorSync" caption.
|
||||
@Published var inspectIsApproximate = false
|
||||
|
||||
/// Per-layer containment for `inspectLab`, in layer order.
|
||||
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
|
||||
|
||||
/// Manual Lab entry fields (`gamutLabEntry*`).
|
||||
@Published var labEntryL = ""
|
||||
@Published var labEntryA = ""
|
||||
@Published var labEntryB = ""
|
||||
|
||||
// MARK: - TIFF sampling
|
||||
|
||||
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
|
||||
@Published var tiffPreviewPNG: Data?
|
||||
@Published var showingTiffPreview = false
|
||||
|
||||
private let environment: AppEnvironment
|
||||
private let profileGamURL: URL?
|
||||
private let fileDialogs = FileDialogService.shared
|
||||
|
||||
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
|
||||
self.environment = environment
|
||||
self.profileGamURL = profileGamURL
|
||||
// Never let the view mount an SCNView without Metal (#147).
|
||||
viewerUnavailable = !GamutSceneAvailability.isAvailable
|
||||
loadTask = Task { await load() }
|
||||
}
|
||||
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
/// Awaits the initial sRGB/profile load — used by tests.
|
||||
func awaitInitialLoad() async {
|
||||
await loadTask?.value
|
||||
}
|
||||
|
||||
func layer(id: String) -> NamedGamut? {
|
||||
layers.first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Initial load
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
|
||||
let reference = try await parse(url: referenceURL)
|
||||
layers.append(NamedGamut(
|
||||
id: Self.srgbLayerID,
|
||||
displayName: "sRGB",
|
||||
role: .reference,
|
||||
mesh: reference,
|
||||
sourceURL: referenceURL))
|
||||
visibleIDs.insert(Self.srgbLayerID)
|
||||
} catch {
|
||||
status = "Could not load gamut: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if let profileGamURL {
|
||||
do {
|
||||
let profile = try await parse(url: profileGamURL)
|
||||
layers.append(NamedGamut(
|
||||
id: Self.profileLayerID,
|
||||
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
|
||||
role: .profileA,
|
||||
mesh: profile,
|
||||
sourceURL: profileGamURL))
|
||||
visibleIDs.insert(Self.profileLayerID)
|
||||
} catch {
|
||||
// #24 — a missing/unparseable profile mesh is info, not fatal.
|
||||
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
// MARK: - Compare slot (profile B)
|
||||
|
||||
/// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot.
|
||||
func openCompareGam() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.gamutFileURL
|
||||
: fileDialogs.selectGamutFile()
|
||||
guard let url else { return }
|
||||
Task { await loadCompareGam(url: url) }
|
||||
}
|
||||
|
||||
/// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam`
|
||||
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
|
||||
func openCompareProfile() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.gamutProfileURL
|
||||
: fileDialogs.selectProfileFile()
|
||||
guard let url else { return }
|
||||
Task { await loadCompareProfile(url: url) }
|
||||
}
|
||||
|
||||
/// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A.
|
||||
func removeCompare() {
|
||||
layers.removeAll { $0.id == Self.compareLayerID }
|
||||
visibleIDs.remove(Self.compareLayerID)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
/// Parses `url` into the compare slot. Internal for tests — the UI
|
||||
/// reaches it through `openCompareGam` / `openCompareProfile`.
|
||||
func loadCompareGam(url: URL) async {
|
||||
do {
|
||||
let mesh = try await parse(url: url)
|
||||
installCompare(NamedGamut(
|
||||
id: Self.compareLayerID,
|
||||
displayName: url.deletingPathExtension().lastPathComponent,
|
||||
role: .profileB,
|
||||
mesh: mesh,
|
||||
sourceURL: url))
|
||||
} catch {
|
||||
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot.
|
||||
/// Internal for tests.
|
||||
func loadCompareProfile(url: URL) async {
|
||||
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
|
||||
do {
|
||||
if !FileManager.default.fileExists(atPath: gamURL.path) {
|
||||
_ = try await environment.runner.runIccgamut(
|
||||
config: IccgamutConfig(profileURL: url))
|
||||
}
|
||||
await loadCompareGam(url: gamURL)
|
||||
} catch {
|
||||
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A third profile replaces B — the compare slot never stacks and
|
||||
/// sRGB is never touched.
|
||||
private func installCompare(_ gamut: NamedGamut) {
|
||||
if layers.contains(where: { $0.id == gamut.id }) {
|
||||
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
|
||||
}
|
||||
layers.removeAll { $0.id == gamut.id }
|
||||
layers.append(gamut)
|
||||
visibleIDs.insert(gamut.id)
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
// MARK: - Status line
|
||||
|
||||
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
|
||||
/// Unloaded layers are omitted; the volume clause appears only when
|
||||
/// both volumes are finite and positive.
|
||||
private func refreshStatus() {
|
||||
var clauses = layers.map {
|
||||
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
|
||||
}
|
||||
if let srgb = layer(id: Self.srgbLayerID),
|
||||
let profile = layer(id: Self.profileLayerID) {
|
||||
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
|
||||
let profileVolume = GamutGeometry.volume(of: profile.mesh)
|
||||
if srgbVolume.isFinite, srgbVolume > 0,
|
||||
profileVolume.isFinite, profileVolume > 0 {
|
||||
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
|
||||
}
|
||||
}
|
||||
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Inspect
|
||||
|
||||
/// Whether every manual Lab field parses as a number; the Inspect
|
||||
/// button is disabled while this is false.
|
||||
var canInspectLab: Bool {
|
||||
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
|
||||
}
|
||||
|
||||
/// Runs containment for a Lab point and publishes the inspect row.
|
||||
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
|
||||
inspectLab = lab
|
||||
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
|
||||
inspectIsApproximate = isApproximate
|
||||
inspectResults = layers.map {
|
||||
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
|
||||
}
|
||||
}
|
||||
|
||||
/// Click on the axis scaffold or empty background returns the panel
|
||||
/// to idle.
|
||||
func clearInspect() {
|
||||
inspectLab = nil
|
||||
inspectSwatch = nil
|
||||
inspectIsApproximate = false
|
||||
inspectResults = []
|
||||
}
|
||||
|
||||
/// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`.
|
||||
/// The swatch is the nearest vertex colour of the hit layer's mesh.
|
||||
func inspectSceneHit(world: SIMD3<Float>, layerID: String) {
|
||||
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
|
||||
var swatch: DisplayRGB?
|
||||
var approximate = true
|
||||
if let mesh = layer(id: layerID)?.mesh,
|
||||
let nearest = mesh.vertices.min(by: {
|
||||
simd_distance($0.position, world) < simd_distance($1.position, world)
|
||||
}) {
|
||||
swatch = nearest.rgb
|
||||
approximate = false
|
||||
}
|
||||
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
|
||||
}
|
||||
|
||||
/// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis
|
||||
/// values still run containment and report `?` outside every hull.
|
||||
func inspectEnteredLab() {
|
||||
guard let l = Double(labEntryL),
|
||||
let a = Double(labEntryA),
|
||||
let b = Double(labEntryB) else { return }
|
||||
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
|
||||
}
|
||||
|
||||
// MARK: - TIFF sampling
|
||||
|
||||
/// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG
|
||||
/// preview (#58), and open the click-to-sample sheet.
|
||||
func openTiffSample() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.gamutTiffURL
|
||||
: fileDialogs.selectTiffFile()
|
||||
guard let url else { return }
|
||||
guard let png = TiffPreview.previewPNG(tiff: url) else {
|
||||
noticeText = "Could not decode TIFF preview."
|
||||
return
|
||||
}
|
||||
tiffPreviewPNG = png
|
||||
showingTiffPreview = true
|
||||
}
|
||||
|
||||
/// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50.
|
||||
func sampleTiffPixel(r: Int, g: Int, b: Int) {
|
||||
inspect(
|
||||
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
|
||||
swatch: DisplayRGB(
|
||||
r: Double(r) / 255.0,
|
||||
g: Double(g) / 255.0,
|
||||
b: Double(b) / 255.0),
|
||||
isApproximate: true)
|
||||
showingTiffPreview = false
|
||||
}
|
||||
|
||||
/// Parses a `.gam` file off the main actor so large meshes do not
|
||||
/// stall the UI.
|
||||
private func parse(url: URL) async throws -> GamutMesh {
|
||||
try await Task.detached {
|
||||
try GamutMeshParser.parse(url: url)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Reusable help overlay badge that does not reflow layout (#171).
|
||||
///
|
||||
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
|
||||
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
|
||||
/// available on hover, so the overlay is purely a visual cue in help mode.
|
||||
struct HelpOverlay: ViewModifier {
|
||||
let text: String
|
||||
@Binding var showing: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.help(text)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if showing {
|
||||
Image(systemName: "questionmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.offset(x: 8, y: -8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Adds a non-reflowing help overlay to the view.
|
||||
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
|
||||
modifier(HelpOverlay(text: text, showing: showing))
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import SwiftUI
|
||||
@main
|
||||
struct ICCeryApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var workflow: TargetWorkflowViewModel
|
||||
@StateObject private var workflow: TargetWorkflowViewModel
|
||||
|
||||
init() {
|
||||
let environment = AppEnvironment.live()
|
||||
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
||||
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
|
||||
try? AppPaths.ensureDirectories()
|
||||
// Log level is runtime state — apply persisted settings at
|
||||
// startup (#158); the Settings sheet re-applies on save.
|
||||
@@ -17,15 +17,22 @@ struct ICCeryApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||
Window("ICCery", id: "main") {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
|
||||
// metrics are applied by AppDelegate once the window exists.
|
||||
WindowGroup("ICCery") {
|
||||
RootView(workflow: workflow)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 800)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultPosition(.center)
|
||||
.commands {
|
||||
// Single-window app: the File menu carries the project
|
||||
// commands (issue #149). Replacing `.newItem` keeps
|
||||
// SwiftUI's empty default New from stacking (R19 — the
|
||||
// group is filled, so no second New appears).
|
||||
CommandGroup(replacing: .newItem) {
|
||||
ProjectCommands(workflow: workflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +43,62 @@ struct ICCeryApp: App {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var terminationRequested = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// SwiftUI scenes launched by XCTest stay `.runningBackground`
|
||||
// unless the app takes regular activation and orders the window
|
||||
// front (CI run 29804).
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
for window in NSApp.windows {
|
||||
configureMainWindow(window)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
|
||||
/// not get a window that hangs off-screen — XCTest then reports
|
||||
/// sidebar controls at negative x as not hittable (run 34864198118).
|
||||
private func configureMainWindow(_ window: NSWindow) {
|
||||
let desired = NSSize(width: 1280, height: 800)
|
||||
let minimum = NSSize(width: 1100, height: 700)
|
||||
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
|
||||
?? NSRect(origin: .zero, size: desired)
|
||||
|
||||
window.contentMinSize = NSSize(
|
||||
width: min(minimum.width, visible.width),
|
||||
height: min(minimum.height, visible.height)
|
||||
)
|
||||
window.setContentSize(NSSize(
|
||||
width: min(desired.width, visible.width),
|
||||
height: min(desired.height, max(minimum.height, visible.height - 40))
|
||||
))
|
||||
window.center()
|
||||
|
||||
var frame = window.frame
|
||||
if frame.width > visible.width {
|
||||
frame.size.width = visible.width
|
||||
}
|
||||
if frame.height > visible.height {
|
||||
frame.size.height = visible.height
|
||||
}
|
||||
frame.origin.x = min(
|
||||
max(frame.origin.x, visible.minX),
|
||||
visible.maxX - frame.width
|
||||
)
|
||||
frame.origin.y = min(
|
||||
max(frame.origin.y, visible.minY),
|
||||
visible.maxY - frame.height
|
||||
)
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||
/// main window when none are visible.
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
|
||||
struct LicenseWindowView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let icceryLicense: String
|
||||
private let argyllLicense: String
|
||||
|
||||
init() {
|
||||
// ICCery license from LICENCE.md (embedded at compile time)
|
||||
self.icceryLicense = """
|
||||
# LICENCE
|
||||
|
||||
**Copyright (c) 2026 Gordon Bolton**
|
||||
**All Rights Reserved.**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||
|
||||
Under this licence, you may **not**:
|
||||
|
||||
* Modify, alter, or create derivative works of the Software.
|
||||
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||
* Use the Software for any commercial or production purpose.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Bundled ArgyllCMS sidecar binaries
|
||||
|
||||
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||
"""
|
||||
|
||||
// ArgyllCMS license from bundled License.txt
|
||||
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
|
||||
let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||
self.argyllLicense = content
|
||||
} else {
|
||||
self.argyllLicense = """
|
||||
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
|
||||
|
||||
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
|
||||
A copy of the license should be present at Resources/Argyll/License.txt.
|
||||
|
||||
See: https://git.i3omb.com/gronod/argyllcms/releases
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title bar
|
||||
HStack {
|
||||
Text("Licenses & Attribution")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("closeLicenseBtn")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.panel.opacity(0.9))
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Section 1: ICCery License
|
||||
licenseSection(
|
||||
title: "ICCery License",
|
||||
content: icceryLicense,
|
||||
identifier: "icceryLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 2: ArgyllCMS License
|
||||
licenseSection(
|
||||
title: "ArgyllCMS License (AGPLv3)",
|
||||
content: argyllLicense,
|
||||
identifier: "argyllLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 3: Attribution & Links
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Attribution & Links")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("attributionHeader")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
|
||||
destination: URL(string: "https://www.argyllcms.com/")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllUpstreamLink")
|
||||
|
||||
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
|
||||
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllForkLink")
|
||||
}
|
||||
|
||||
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("agplIsolationNote")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("attributionSection")
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("licenseWindow")
|
||||
}
|
||||
|
||||
private func licenseSection(title: String, content: String, identifier: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier(identifier + "Header")
|
||||
|
||||
Text(content)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier(identifier + "Content")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||