Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d228594bd3 | ||
|
|
6adbf99bd9 | ||
|
|
d8c41444b9 | ||
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 | ||
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 | ||
|
|
bb3cc3c858 | ||
|
|
eb73bf0106 | ||
|
|
736fd080cb | ||
|
|
14cd544e05 | ||
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 | ||
|
|
1b91b0a94e | ||
|
|
890e7281eb | ||
|
|
ccaad2d940 | ||
|
|
7e5e20cb69 | ||
|
|
e72039c123 | ||
|
|
fa02e774ca | ||
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
ad7b91cf91 | ||
|
|
8b931e3625 | ||
|
|
73b18dec5b | ||
|
|
3bc0d14a34 | ||
|
|
4aa2815c6e | ||
|
|
8596f15d52 | ||
|
|
407136c5a2 | ||
|
|
83b5f6bf75 | ||
|
|
16df18127c | ||
|
|
78baa49e61 | ||
|
|
04a563c54a | ||
|
|
2cbd43470d | ||
|
|
14f9e6f78d | ||
|
|
f16825be72 | ||
|
|
9fcd21dc2d | ||
|
|
f5c57649e1 | ||
|
|
e967502ea7 | ||
|
|
eb3ae08cf9 | ||
|
|
3d8e70be62 | ||
|
|
f43c275152 | ||
|
|
48d2816c73 | ||
|
|
b494b4fffd | ||
|
|
d37dcedfd9 |
@@ -17,9 +17,16 @@ jobs:
|
||||
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: |
|
||||
@@ -30,20 +37,31 @@ jobs:
|
||||
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.
|
||||
# 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' \
|
||||
@@ -52,7 +70,10 @@ jobs:
|
||||
ARCHS="$(uname -m)" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-'
|
||||
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
|
||||
@@ -60,6 +81,7 @@ jobs:
|
||||
# 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
|
||||
@@ -68,6 +90,7 @@ jobs:
|
||||
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
|
||||
@@ -90,6 +113,7 @@ jobs:
|
||||
# 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)"
|
||||
@@ -154,7 +178,7 @@ jobs:
|
||||
# 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()
|
||||
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 \
|
||||
@@ -166,7 +190,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload UI test xcresult
|
||||
if: failure()
|
||||
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
|
||||
@@ -181,15 +205,22 @@ jobs:
|
||||
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: scripts/ensure-host-tools.sh
|
||||
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 }}
|
||||
|
||||
@@ -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,6 +18,9 @@ Vendor/Argyll/
|
||||
# XcodeGen output (regenerate with `make gen`)
|
||||
ICCery.xcodeproj/
|
||||
|
||||
# Local-only developer helpers (never committed)
|
||||
/set-version.sh
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ 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/*`.
|
||||
@@ -54,3 +61,14 @@ Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for rel
|
||||
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.
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# BUILD-PLAN.md — ICCery v2 Mac
|
||||
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + 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.
|
||||
@@ -20,11 +20,12 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
||||
| 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 |
|
||||
|
||||
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`.
|
||||
|
||||
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
|
||||
|
||||
## Branch taxonomy
|
||||
|
||||
|
||||
@@ -20,8 +20,14 @@ public enum ArtefactFiles {
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String, buildDate: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.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
|
||||
@@ -37,4 +43,15 @@ public enum ArtefactFiles {
|
||||
|
||||
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))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ public enum CupsOptionsFilter {
|
||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||
"EpsonColorMode", "ColorModel",
|
||||
// Quality
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
||||
"CNIJQuality", "StpQuality", "OutputMode",
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||
"PrintQuality", "StpQuality", "OutputMode",
|
||||
// Duplex
|
||||
"Duplex", "sides",
|
||||
]
|
||||
|
||||
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
||||
|
||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||
/// Precedence is deterministic, not positional (#181): unqualified
|
||||
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||
/// base English labels — and a qualified-only id still gets its
|
||||
/// first-seen qualified label (R9).
|
||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||
for rawLine in ppd.split(separator: "\n") {
|
||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||
// Only strip when the part before the first `.` looks like
|
||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||
// are left alone.
|
||||
// are left alone. The qualifier is recorded for precedence
|
||||
// rather than dropped (#181).
|
||||
var qualifier: String?
|
||||
if let dot = line.firstIndex(of: ".") {
|
||||
let prefix = line[..<dot]
|
||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
||||
&& (prefix.count == 2 || prefix.contains("_"))
|
||||
let candidate = line[line.index(after: dot)...]
|
||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||
qualifier = prefix.lowercased()
|
||||
line = String(candidate)
|
||||
}
|
||||
}
|
||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
||||
rest = String(rest[..<colon])
|
||||
// `<id>/<Human label>` — human label after the last `/`.
|
||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||
let id = String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let human = String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
||||
let id = ppdUnescape(String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
guard !id.isEmpty else { continue }
|
||||
hits[id, default: []].append(
|
||||
(qualifier, human.isEmpty ? id : human))
|
||||
}
|
||||
var map: [String: String] = [:]
|
||||
for (id, candidates) in hits {
|
||||
// First occurrence wins inside each qualifier class, in
|
||||
// file order — same as the old sequential behaviour.
|
||||
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||
?? candidates.first { $0.qualifier == "en" }?.label
|
||||
?? candidates[0].label
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||
/// `>` passes through untouched (#181).
|
||||
private static func ppdUnescape(_ text: String) -> String {
|
||||
var result = ""
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
guard text[index] == "<",
|
||||
let hexEnd = text.index(
|
||||
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||
let byte = UInt8(
|
||||
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||
else {
|
||||
result.append(text[index])
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
result.append(Character(UnicodeScalar(byte)))
|
||||
index = text.index(after: hexEnd)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Detection (docs/11)
|
||||
|
||||
/// Media-type option key in preference order — used both to read a
|
||||
@@ -223,13 +266,61 @@ public enum CupsParsers {
|
||||
}
|
||||
|
||||
/// Media type from a captured `key=value key=value` options string.
|
||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
||||
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||
/// `StpMediaType` (#186 capture-return).
|
||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||
return v
|
||||
}
|
||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||
for key in mediaTypeKeys where key != "MediaType" {
|
||||
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Print-quality option key in preference order — vendor-first,
|
||||
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
|
||||
/// drivers they are colour-mode keys, not quality (#180).
|
||||
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
|
||||
public static let qualityKeys = [
|
||||
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
|
||||
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
|
||||
"EPIJ_Quality", "OutputMode", "Resolution",
|
||||
]
|
||||
|
||||
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
|
||||
qualityKeys.first { optionKeys.contains($0) }
|
||||
}
|
||||
|
||||
/// A single value from a captured `key=value key=value` options
|
||||
/// string — case-insensitive key match (#183 capture-return).
|
||||
public static func extractOption(
|
||||
named key: String,
|
||||
fromOptionsString options: String
|
||||
) -> String? {
|
||||
lpoptions(options).first {
|
||||
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||
}?.value
|
||||
}
|
||||
|
||||
/// Print-quality token from a captured options string — the queue's
|
||||
/// quality key is detected from the roster before extracting (#183).
|
||||
public static func extractQuality(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
guard let key = detectQualityKey(
|
||||
optionKeys: Set(pairs.map(\.key)))
|
||||
else { return nil }
|
||||
return pairs.first(where: { $0.key == key })?.value
|
||||
}
|
||||
|
||||
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
|
||||
public static func extractOrientation(fromOptionsString options: String) -> String? {
|
||||
extractOption(named: "orientation-requested", fromOptionsString: options)
|
||||
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||
}
|
||||
|
||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||
|
||||
@@ -132,8 +132,28 @@ public struct CupsService: Sendable {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Print quality — the detected roster key's listing maps to
|
||||
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||
var qualities: [PrinterQuality] = []
|
||||
var qualityDefault: String?
|
||||
let qualityKey = CupsParsers.detectQualityKey(
|
||||
optionKeys: Set(listings.map(\.key)))
|
||||
if let qualityKey,
|
||||
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||
let labels = ppd.map {
|
||||
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
|
||||
} ?? [:]
|
||||
qualities = listing.choices.map {
|
||||
PrinterQuality(id: $0, name: labels[$0] ?? $0)
|
||||
}
|
||||
qualityDefault = listing.defaultChoice
|
||||
}
|
||||
|
||||
return PrinterCapabilities(
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||
qualityKey: qualityKey, qualities: qualities,
|
||||
qualityDefault: qualityDefault)
|
||||
}
|
||||
|
||||
/// The set of option keys a queue advertises — input to
|
||||
@@ -167,7 +187,11 @@ public struct CupsService: Sendable {
|
||||
|
||||
private func loadPPD(for queue: String) -> String? {
|
||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||
return try? String(contentsOf: url, encoding: .utf8)
|
||||
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||
// when UTF-8 decoding fails outright.
|
||||
return (try? String(contentsOf: url, encoding: .utf8))
|
||||
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||
}
|
||||
|
||||
// MARK: - Spawn
|
||||
|
||||
@@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable {
|
||||
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||
/// <captured cups_options>
|
||||
/// <media_type, if no media key already captured>
|
||||
/// <quality, if no quality key already captured> (#183)
|
||||
/// <driver bypass, if no bypass key captured>
|
||||
/// <orientation-requested=3|4, unless captured>
|
||||
/// <PageSize, unless captured>
|
||||
@@ -82,6 +83,15 @@ public enum LpArgs {
|
||||
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||
}
|
||||
|
||||
// Print quality — after media, before the driver bypass; the
|
||||
// detected queue key is skipped when already captured (#183).
|
||||
if let quality = options.quality,
|
||||
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys),
|
||||
!addedKeys.contains(qualityKey.lowercased()) {
|
||||
addedKeys.insert(qualityKey.lowercased())
|
||||
argv += ["-o", "\(qualityKey)=\(quality)"]
|
||||
}
|
||||
|
||||
// Driver colour bypass — when no bypass key was captured. NOT
|
||||
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||
let capturedKeys = Set(
|
||||
|
||||
@@ -66,6 +66,18 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
|
||||
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
|
||||
public struct PrinterQuality: Codable, Equatable, Sendable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
public var trays: [PrinterTray]
|
||||
public var paperSizes: [PrinterPaperSize]
|
||||
@@ -73,17 +85,29 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
/// Always `true` on macOS (spec parity — CUPS honours
|
||||
/// `orientation-requested`).
|
||||
public var supportsOrientation: Bool
|
||||
/// The queue's detected quality enumeration key
|
||||
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||
public var qualityKey: String?
|
||||
public var qualities: [PrinterQuality]
|
||||
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||
public var qualityDefault: String?
|
||||
|
||||
public init(
|
||||
trays: [PrinterTray] = [],
|
||||
paperSizes: [PrinterPaperSize] = [],
|
||||
mediaTypes: [PrinterMediaType] = [],
|
||||
supportsOrientation: Bool = true
|
||||
supportsOrientation: Bool = true,
|
||||
qualityKey: String? = nil,
|
||||
qualities: [PrinterQuality] = [],
|
||||
qualityDefault: String? = nil
|
||||
) {
|
||||
self.trays = trays
|
||||
self.paperSizes = paperSizes
|
||||
self.mediaTypes = mediaTypes
|
||||
self.supportsOrientation = supportsOrientation
|
||||
self.qualityKey = qualityKey
|
||||
self.qualities = qualities
|
||||
self.qualityDefault = qualityDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +120,12 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
public var paperSource: Int?
|
||||
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||
public var orientation: String?
|
||||
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
||||
/// Stage 2 paper token → `PageSize=` (skipped if captured, #183).
|
||||
public var paperSize: String?
|
||||
public var mediaType: String?
|
||||
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if
|
||||
/// captured, #183).
|
||||
public var quality: String?
|
||||
public var ppdUncorrectedPassthrough: Bool?
|
||||
/// Space-separated `key=value` captured from
|
||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||
@@ -109,6 +136,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
orientation: String? = nil,
|
||||
paperSize: String? = nil,
|
||||
mediaType: String? = nil,
|
||||
quality: String? = nil,
|
||||
ppdUncorrectedPassthrough: Bool? = nil,
|
||||
cupsOptions: String? = nil
|
||||
) {
|
||||
@@ -116,6 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
self.orientation = orientation
|
||||
self.paperSize = paperSize
|
||||
self.mediaType = mediaType
|
||||
self.quality = quality
|
||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||
self.cupsOptions = cupsOptions
|
||||
}
|
||||
|
||||
@@ -1,45 +1,76 @@
|
||||
# ICCery
|
||||
|
||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
|
||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from
|
||||
chart generation through measurement, `colprof`, verification, and ColorSync
|
||||
install. It is **not** a colour engine.
|
||||
|
||||
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
**End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
|
||||
covers every screen (Getting Started through Troubleshooting). This README is
|
||||
for building, packaging, and contributing.
|
||||
|
||||
All measurement, chart generation, and profile mathematics live in the
|
||||
[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned
|
||||
as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||
| Version | 2.0.0 |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||
| CI | Gitea Actions `macos-12` runner |
|
||||
| Default branch | `develop` |
|
||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
|
||||
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` |
|
||||
| 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 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore
|
||||
2. **Stage 1** — `targen` → `.ti1`
|
||||
3. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
||||
4. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
||||
5. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
||||
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||
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 CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging.
|
||||
Plus:
|
||||
|
||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
||||
- **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename
|
||||
- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv`
|
||||
- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal`
|
||||
- **Spot Read** — live one-patch Lab/XYZ from the instrument
|
||||
- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE
|
||||
- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect
|
||||
- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging
|
||||
- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma)
|
||||
|
||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n,
|
||||
Windows/Linux print trees, in-process Argyll, App Sandbox.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14+
|
||||
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
||||
- Network once, to fetch Argyll sidecars
|
||||
To **run** a packaged build:
|
||||
|
||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
||||
- macOS 12.0 Monterey or later (Intel or Apple silicon)
|
||||
|
||||
To **build** on the supported CI/host floor:
|
||||
|
||||
- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7)
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current
|
||||
formula needs Xcode 15.3; CI installs the pinned zip via
|
||||
`scripts/ensure-host-tools.sh`)
|
||||
- Network once, to fetch Argyll sidecars
|
||||
- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild`
|
||||
(`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`)
|
||||
|
||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in
|
||||
`ICCery.entitlements`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -49,7 +80,7 @@ cd iccery-v2-mac
|
||||
git checkout develop
|
||||
|
||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
||||
make test # xcodegen + xcodebuild build test
|
||||
make test # xcodegen + xcodebuild build test (host arch)
|
||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
```
|
||||
|
||||
@@ -62,9 +93,14 @@ xcodebuild test -scheme ICCery \
|
||||
ARCHS="$(uname -m)"
|
||||
```
|
||||
|
||||
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
|
||||
`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.
|
||||
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
|
||||
@@ -74,9 +110,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
|
||||
export GITEA_TOKEN=… # private releases
|
||||
```
|
||||
|
||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and
|
||||
`Packages/ICCeryCore/.build`.
|
||||
|
||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
|
||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
|
||||
and regenerate.
|
||||
|
||||
## Release packaging
|
||||
|
||||
@@ -84,11 +122,48 @@ Do not open the generated xcodeproj as the source of truth. Edit `project.yml` a
|
||||
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
|
||||
```
|
||||
|
||||
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`), and
|
||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay
|
||||
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed.
|
||||
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:
|
||||
|
||||
@@ -99,6 +174,8 @@ Environment variables read by the pipeline:
|
||||
| `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
|
||||
|
||||
@@ -110,30 +187,47 @@ 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.
|
||||
`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`, `printcal`, etc.) use the event bus; one-shot tools 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`, …). 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. SwiftUI `@Observable` for new state.
|
||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
||||
- **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)
|
||||
# full suite (host arch) — same as CI
|
||||
xcodebuild test -scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
ARCHS="$(uname -m)"
|
||||
|
||||
# to compile-check both slices instead:
|
||||
# fat compile-check (not the default test path):
|
||||
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
|
||||
# examples
|
||||
@@ -143,9 +237,24 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||
-only-testing:ICCeryUITests/Milestone5UITests
|
||||
```
|
||||
|
||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
|
||||
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||
GitHub) runs `build-and-test` then `package` on
|
||||
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||
test job and still package. `pull_request` is wired for **`develop` only**.
|
||||
The GitHub file is the same pipeline on `macos-14` (github.com retired
|
||||
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
|
||||
DMGs.
|
||||
|
||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||
that a real `.gam` / `.icc` was extracted.
|
||||
|
||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are
|
||||
manual and block release, not compile.
|
||||
|
||||
`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is
|
||||
dropped on a fast mock exit; that is a `ProcessManager` drain race, not a
|
||||
missing fixture.
|
||||
|
||||
## Instruments
|
||||
|
||||
@@ -156,11 +265,18 @@ Detected via bundled `instlist`:
|
||||
- SpyderPrint (`p3`)
|
||||
- SpectroScan (`SS`)
|
||||
- DTP20 / 22 / 41 / 51
|
||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
|
||||
`/spectro\s?scan|i1io/i`
|
||||
|
||||
## Docs
|
||||
|
||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
||||
| Where | Audience |
|
||||
|---|---|
|
||||
| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting |
|
||||
| [`docs/`](docs/README.md) | Functional spec (normative for implementers) |
|
||||
| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules |
|
||||
|
||||
Implementation order in `docs/`:
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
@@ -168,29 +284,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
|
||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
||||
| [`docs/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 |
|
||||
|
||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
||||
|
||||
## Git
|
||||
|
||||
```
|
||||
develop
|
||||
└── milestone/m10-studio # M10 integration branch
|
||||
└── feat/<issue>-<slug> # one issue per branch
|
||||
develop # integration; PRs land here unless a milestone branch is announced
|
||||
main # protected release line (PR from develop)
|
||||
feat/<issue>-<slug>
|
||||
fix/<issue>-<slug>
|
||||
```
|
||||
|
||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||
branch is used only while that milestone is assembling — 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.
|
||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing
|
||||
and personal evaluation only unless a separate grant says otherwise.
|
||||
|
||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
|
||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay
|
||||
subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning
|
||||
via `$PATH`, is a licence break.
|
||||
|
||||
## Related
|
||||
|
||||
- [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)
|
||||
|
||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
||||
struct AboutView: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var showingLicenses = false
|
||||
|
||||
private let info = ArtefactFiles.appInfo()
|
||||
|
||||
var body: some View {
|
||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button("View Licenses") {
|
||||
showingLicenses = true
|
||||
}
|
||||
.controlSize(.large)
|
||||
.accessibilityIdentifier("viewLicensesBtn")
|
||||
|
||||
Button("Close") {
|
||||
onClose()
|
||||
}
|
||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
||||
.background(Theme.panel)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("aboutDialog")
|
||||
.sheet(isPresented: $showingLicenses) {
|
||||
LicenseWindowView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,12 @@ enum UITestHooks {
|
||||
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)
|
||||
|
||||
@@ -130,9 +136,19 @@ enum UITestHooks {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? queue,
|
||||
options: PrintOptions(
|
||||
orientation: options.flatMap {
|
||||
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||
},
|
||||
paperSize: options.flatMap {
|
||||
CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: $0)
|
||||
},
|
||||
mediaType: options.flatMap {
|
||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||
},
|
||||
quality: options.flatMap {
|
||||
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||
},
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: options))
|
||||
default:
|
||||
|
||||
@@ -7,102 +7,135 @@ struct CalibrationView: View {
|
||||
@ObservedObject var wizard: WizardViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Calibrate Printer")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
Form {
|
||||
Section("Wedge Settings") {
|
||||
Picker("Colour Space", selection: $model.colourSpace) {
|
||||
Text("RGB").tag(ColourSpace.rgb)
|
||||
Text("CMYK").tag(ColourSpace.cmyk)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Steps per channel")
|
||||
Spacer()
|
||||
TextField("", value: $model.steps, format: .number)
|
||||
.frame(width: 60)
|
||||
.accessibilityIdentifier("calSteps")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("White patches")
|
||||
Spacer()
|
||||
TextField("", value: $model.whitePatches, format: .number)
|
||||
.frame(width: 60)
|
||||
}
|
||||
|
||||
if model.colourSpace == .cmyk {
|
||||
HStack {
|
||||
Text("Ink-limit exploration")
|
||||
Spacer()
|
||||
TextField("", text: $model.inkLimit)
|
||||
.frame(width: 60)
|
||||
.accessibilityIdentifier("calInkExplore")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||
}
|
||||
|
||||
Section("Workflow") {
|
||||
HStack(spacing: 12) {
|
||||
Button("Generate Target") { model.generateTarget() }
|
||||
.accessibilityIdentifier("btnCalGenerate")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Create Layout & Print") { model.createLayout() }
|
||||
.accessibilityIdentifier("btnCalLayout")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Measure") { model.measureChart() }
|
||||
.accessibilityIdentifier("btnCalMeasure")
|
||||
.disabled(model.calibrationTi3URL == nil)
|
||||
|
||||
Button("Compute Curves") { model.computeCurves() }
|
||||
.accessibilityIdentifier("btnCalCompute")
|
||||
.disabled(!model.canCompute)
|
||||
}
|
||||
|
||||
if let url = model.computedCalURL {
|
||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||
.accessibilityIdentifier("calApplyToggle")
|
||||
|
||||
Text("Loaded: \(url.lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.calibrationLog.isEmpty {
|
||||
Section("Log") {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.calibrationLog, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 80, maxHeight: 120)
|
||||
}
|
||||
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") { model.returnToProfiling() }
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ 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(
|
||||
@@ -94,6 +95,8 @@ struct GamutView: View {
|
||||
Divider().overlay(Theme.border)
|
||||
statusLine
|
||||
inspectPanel
|
||||
Divider().overlay(Theme.border)
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.background(Theme.background)
|
||||
@@ -142,8 +145,12 @@ struct GamutView: View {
|
||||
Text(layer?.displayName ?? fallback)
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(layer == nil || viewModel.viewerUnavailable)
|
||||
.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)")
|
||||
}
|
||||
|
||||
@@ -318,6 +325,21 @@ struct GamutView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -377,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
|
||||
context.coordinator.installKeyMonitor()
|
||||
context.coordinator.installClickGesture()
|
||||
|
||||
// No GPU → the docs/18 fallback; never respawn the view in a loop.
|
||||
// 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() }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
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
|
||||
@@ -73,6 +86,8 @@ final class GamutViewModel: ObservableObject {
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|
||||
|| media.captureColourSpaceMismatch
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||
// children (#146); Group blocks are layout-transparent, so field
|
||||
// order and every docs/21 id are unchanged.
|
||||
private var fields: some View {
|
||||
Group {
|
||||
TextField("Name", text: $media.saveMediaName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaName")
|
||||
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
|
||||
TextField("Ink set", text: $media.saveMediaInk)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("saveMediaInk")
|
||||
}
|
||||
}
|
||||
|
||||
private var readOnlyRows: some View {
|
||||
Group {
|
||||
captureRow("Printer", value: printerCaption,
|
||||
identifier: "saveMediaPrinter")
|
||||
captureRow("Preset",
|
||||
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
|
||||
value: workflow.profile.calibrationFile.isEmpty
|
||||
? "None" : workflow.profile.calibrationFile,
|
||||
identifier: "saveMediaCal")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
|
||||
fields
|
||||
readOnlyRows
|
||||
Toggle("Apply calibration to profile",
|
||||
isOn: $media.saveMediaApplyCal)
|
||||
.disabled(!media.calApplyable)
|
||||
@@ -206,6 +220,15 @@ struct ManageMediaDialog: View {
|
||||
.accessibilityIdentifier("mediaLibraryList")
|
||||
.frame(minHeight: 260)
|
||||
|
||||
if let notice = media.manageApplyNotice {
|
||||
Text(notice)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("manageMediaNotice")
|
||||
.accessibilityValue(notice)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Apply selected") {
|
||||
if let id = selection,
|
||||
|
||||
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
@Published var saveMediaApplyCal = false
|
||||
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||
@Published var saveMediaError: String?
|
||||
/// Last failed Apply while Manage is open. The window banner sits
|
||||
/// behind the sheet on Monterey, so the dialog shows this too (#170).
|
||||
@Published var manageApplyNotice: String?
|
||||
|
||||
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||
@@ -120,24 +123,21 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||
@discardableResult
|
||||
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||
manageApplyNotice = nil
|
||||
guard let r = try? recipe.validated() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Media recipe is invalid — not applied.", kind: .error)
|
||||
return false
|
||||
return failApply("Media recipe is invalid — not applied.", kind: .error)
|
||||
}
|
||||
guard let preset = environment.presetStore.all()
|
||||
.first(where: { $0.id == r.presetID })
|
||||
else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Recipe colour space does not match its preset — not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||
@@ -145,8 +145,6 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
// Literal per issue: displayName, not the queue id.
|
||||
workflow.wizard.printerName = r.printerDisplayName
|
||||
|
||||
var succeeded = true
|
||||
|
||||
// Queue: enumerate fresh via the session's serialized path —
|
||||
// listPrinters uses fixed process ids, so an overlapping
|
||||
// enumeration would throw duplicateID. An empty result is a
|
||||
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
workflow.print.selectedPrinter = r.printerID
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Printer \(r.printerDisplayName) is not installed.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Could not enumerate printers — queue left unchanged.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
|
||||
// Calibration — the recipe is authoritative and runs after
|
||||
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Calibration file is missing: \(calPath)", kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Could not load calibration: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
}
|
||||
|
||||
if succeeded {
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
}
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
refreshStaleness()
|
||||
return succeeded
|
||||
return true
|
||||
}
|
||||
|
||||
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
|
||||
manageApplyNotice = text
|
||||
workflow.wizard.showNotice(text, kind: kind)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
@@ -17,6 +17,24 @@ enum PrintPanelError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||
/// opens (#183/#186). Every write is warn-only — the panel still
|
||||
/// opens when a driver ignores a key.
|
||||
struct PrintPanelInitialSelections {
|
||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||
var paperSize: String?
|
||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||
var qualityKey: String?
|
||||
/// The selected quality token.
|
||||
var quality: String?
|
||||
/// The selected media token — written to the queue's detected
|
||||
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
||||
var mediaType: String?
|
||||
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
||||
var orientation: String?
|
||||
}
|
||||
|
||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
@@ -41,7 +59,9 @@ struct PrintPanelService {
|
||||
func showProperties(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
cupsService: CupsService
|
||||
cupsService: CupsService,
|
||||
initialSelections: PrintPanelInitialSelections =
|
||||
PrintPanelInitialSelections()
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
@@ -56,7 +76,8 @@ struct PrintPanelService {
|
||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||
?? []
|
||||
return try runNativePanel(
|
||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||
initialSelections: initialSelections)
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
@@ -64,7 +85,8 @@ struct PrintPanelService {
|
||||
private func runNativePanel(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>
|
||||
optionKeys: Set<String>,
|
||||
initialSelections: PrintPanelInitialSelections
|
||||
) throws -> PrintPropertiesResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
@@ -89,9 +111,26 @@ struct PrintPanelService {
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||
// Initial selections — after `PMSessionDefault*`, before
|
||||
// ColorSync suppression ②–⑤ (locked write order,
|
||||
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||
// print-settings value drivers/capture read AND the
|
||||
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||
applyInitialSelections(
|
||||
initialSelections, to: settings, optionKeys: optionKeys)
|
||||
if let paperToken = initialSelections.paperSize {
|
||||
applyPaperPageFormat(
|
||||
paperToken, printer: printer, session: session,
|
||||
printInfo: printInfo)
|
||||
}
|
||||
boundViaPM = true
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
// Warn — the display name can resolve a *different* queue
|
||||
// (#186 E2: diagnosable, not a proven defect).
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||
+ "falling back to NSPrinter(displayName)")
|
||||
guard let displayName,
|
||||
let nsPrinter = NSPrinter(name: displayName)
|
||||
else {
|
||||
@@ -138,7 +177,9 @@ struct PrintPanelService {
|
||||
|
||||
// ⑥ Capture the user's choices — filtered replay options plus
|
||||
// the media type they picked. Re-fetch the settings handle so
|
||||
// we read back what the modal wrote.
|
||||
// we read back what the modal wrote. Paper size, quality, and
|
||||
// orientation ride back parsed from the captured `k=v` string
|
||||
// (#183); the PDE may rewrite or drop them (R12).
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
@@ -148,6 +189,7 @@ struct PrintPanelService {
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
let capturedOptions = cupsOptions ?? ""
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
? Self.currentPrinterID(
|
||||
@@ -156,11 +198,144 @@ struct PrintPanelService {
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
orientation: CupsParsers.extractOrientation(
|
||||
fromOptionsString: capturedOptions),
|
||||
paperSize: CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: capturedOptions),
|
||||
mediaType: mediaType,
|
||||
quality: CupsParsers.extractQuality(
|
||||
fromOptionsString: capturedOptions),
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
}
|
||||
|
||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||
/// media type, orientation. All warn-only: a driver that ignores
|
||||
/// a key must not keep the panel from opening (R12 surfaces via
|
||||
/// the capture echo instead).
|
||||
private func applyInitialSelections(
|
||||
_ selections: PrintPanelInitialSelections,
|
||||
to settings: PMPrintSettings,
|
||||
optionKeys: Set<String>
|
||||
) {
|
||||
if let paperSize = selections.paperSize {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, "PageSize" as CFString,
|
||||
paperSize as CFString, false), key: "PageSize")
|
||||
}
|
||||
if let key = selections.qualityKey, let value = selections.quality {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, key as CFString,
|
||||
value as CFString, false), key: key)
|
||||
}
|
||||
// Media type via the queue's detected vendor key (#186).
|
||||
if let mediaType = selections.mediaType,
|
||||
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: optionKeys) {
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, mediaKey as CFString,
|
||||
mediaType as CFString, false), key: mediaKey)
|
||||
}
|
||||
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||
if let orientation = selections.orientation {
|
||||
let code = orientation == "landscape" ? "4" : "3"
|
||||
warnOnFailure(PMPrintSettingsSetValue(
|
||||
settings, "orientation-requested" as CFString,
|
||||
code as CFString, false), key: "orientation-requested")
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||
/// around it, and copy it into the printInfo's format (TN2248:
|
||||
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||
/// missed match must not keep the panel from opening.
|
||||
private func applyPaperPageFormat(
|
||||
_ token: String,
|
||||
printer: PMPrinter,
|
||||
session: PMPrintSession,
|
||||
printInfo: NSPrintInfo
|
||||
) {
|
||||
if let custom = Self.customPaperDimensions(from: token) {
|
||||
printInfo.paperSize = NSSize(
|
||||
width: custom.width, height: custom.height)
|
||||
return
|
||||
}
|
||||
var paperList: Unmanaged<CFArray>?
|
||||
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||
let papers = paperList?.takeUnretainedValue()
|
||||
else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMPrinterGetPaperList failed — "
|
||||
+ "paper preselect skipped")
|
||||
return
|
||||
}
|
||||
// The list (and its elements) is owned by the printer —
|
||||
// borrowed, never released.
|
||||
var match: PMPaper?
|
||||
for index in 0..<CFArrayGetCount(papers) {
|
||||
let paper = unsafeBitCast(
|
||||
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||
var idRef: Unmanaged<CFString>?
|
||||
guard PMPaperGetID(paper, &idRef) == 0,
|
||||
let paperID = idRef?.takeUnretainedValue() as String?
|
||||
else { continue }
|
||||
if paperID == token {
|
||||
match = paper
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let paper = match else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: no PMPaper id matches '\(token)'")
|
||||
return
|
||||
}
|
||||
var created: PMPageFormat?
|
||||
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||
let newFormat = created
|
||||
else {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||
+ "for '\(token)'")
|
||||
return
|
||||
}
|
||||
defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) }
|
||||
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||
let destination = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
_ = PMCopyPageFormat(newFormat, destination)
|
||||
printInfo.updateFromPMPageFormat()
|
||||
}
|
||||
|
||||
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||
/// `PMPaper` match and logs instead of guessing a size.
|
||||
static func customPaperDimensions(
|
||||
from token: String
|
||||
) -> (width: Double, height: Double)? {
|
||||
guard token.hasPrefix("Custom.") else { return nil }
|
||||
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||
guard dims.count == 2,
|
||||
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||
width > 0, height > 0
|
||||
else { return nil }
|
||||
return (width, height)
|
||||
}
|
||||
|
||||
private func warnOnFailure(_ status: OSStatus, key: String) {
|
||||
if status != 0 {
|
||||
AppLogger.shared.warn(
|
||||
"Print panel: PMPrintSettingsSetValue(\(key)) "
|
||||
+ "rejected (\(status))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PM helpers
|
||||
|
||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||
|
||||
@@ -7,12 +7,20 @@ import ICCeryCore
|
||||
final class PrintSessionViewModel: ObservableObject {
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
/// Stage 1/2 form state — read for paper seeding/mirroring only;
|
||||
/// `workflow.pageSize` is the printtarg layout and is never written
|
||||
/// back from the print side (#183).
|
||||
weak var workflow: TargetWorkflowViewModel?
|
||||
|
||||
@Published var printers: [Printer] = []
|
||||
@Published var selectedPrinter = ""
|
||||
@Published var printerCaps = PrinterCapabilities()
|
||||
@Published var selectedTray: Int?
|
||||
@Published var selectedMediaType: String?
|
||||
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||
@Published var selectedPaperSize: Int?
|
||||
/// Print-quality option token, e.g. `"303"` (#183).
|
||||
@Published var selectedQuality: String?
|
||||
@Published var printOrientation = "portrait"
|
||||
@Published var capturedCupsOptions: [String: String] = [:]
|
||||
@Published var printNotice: Notice?
|
||||
@@ -75,22 +83,79 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
if selectedQuality == nil {
|
||||
selectedQuality = printerCaps.qualityDefault
|
||||
?? printerCaps.qualities.first?.id
|
||||
}
|
||||
// Caps reload is a re-mirror trigger for the paper picker
|
||||
// (#183 E4) — pageSize + printer changes route here too.
|
||||
seedPaperSelection()
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paper / quality selection (#183)
|
||||
|
||||
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||
/// no match → nil (never guess). Called only on pageSize / printer /
|
||||
/// caps triggers — never on unrelated publishes (R14).
|
||||
func seedPaperSelection() {
|
||||
guard let pageSize = workflow?.pageSize else { return }
|
||||
if pageSize == .custom {
|
||||
let token = customPaperToken()
|
||||
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
|
||||
printerCaps.paperSizes[index].name = token
|
||||
} else {
|
||||
printerCaps.paperSizes.append(
|
||||
PrinterPaperSize(id: 0, name: token))
|
||||
}
|
||||
selectedPaperSize = 0
|
||||
return
|
||||
}
|
||||
selectedPaperSize = printerCaps.paperSizes
|
||||
.first { $0.name == pageSize.rawValue }?.id
|
||||
}
|
||||
|
||||
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
|
||||
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
|
||||
func customPaperToken() -> String {
|
||||
let w = workflow?.customPageW ?? 0
|
||||
let h = workflow?.customPageH ?? 0
|
||||
let wPt = (w * 72.0 / 25.4).rounded()
|
||||
let hPt = (h * 72.0 / 25.4).rounded()
|
||||
return "Custom.\(Int(wPt))x\(Int(hPt))"
|
||||
}
|
||||
|
||||
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
||||
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
||||
/// name. This is what `lp -o PageSize=` sees.
|
||||
var selectedPaperSizeToken: String? {
|
||||
guard let id = selectedPaperSize else { return nil }
|
||||
if id == 0 { return customPaperToken() }
|
||||
return printerCaps.paperSizes.first { $0.id == id }?.name
|
||||
}
|
||||
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
let selections = PrintPanelInitialSelections(
|
||||
paperSize: selectedPaperSizeToken,
|
||||
qualityKey: printerCaps.qualityKey,
|
||||
quality: selectedQuality,
|
||||
mediaType: selectedMediaType,
|
||||
orientation: printOrientation)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
cupsService: cups,
|
||||
initialSelections: selections)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
@@ -111,6 +176,21 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
// Capture-return (#183/#186): a dialog paper/quality/
|
||||
// orientation change updates the Stage 2 selections —
|
||||
// never `workflow.pageSize` (printtarg layout is
|
||||
// sacred).
|
||||
if let paper = result.options.paperSize,
|
||||
let match = printerCaps.paperSizes
|
||||
.first(where: { $0.name == paper }) {
|
||||
selectedPaperSize = match.id
|
||||
}
|
||||
if let quality = result.options.quality {
|
||||
selectedQuality = quality
|
||||
}
|
||||
if let orientation = result.options.orientation {
|
||||
printOrientation = orientation
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Settings captured for \(selectedPrinter).",
|
||||
@@ -122,7 +202,7 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||
func printAllPages(from result: PrinttargResult) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
@@ -132,7 +212,7 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
try await spool(page, index: page.index)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
@@ -156,13 +236,13 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||
func printPage(_ page: GalleryPage) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
try await spool(page, index: page.index)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||
@@ -180,14 +260,18 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
// The Stage 2 paper token is what `lp -o PageSize=` sees;
|
||||
// `workflow.pageSize` remains the printtarg layout input only
|
||||
// (#183).
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
paperSize: selectedPaperSizeToken,
|
||||
mediaType: selectedMediaType,
|
||||
quality: selectedQuality,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
|
||||
@@ -39,6 +39,7 @@ struct RootView: View {
|
||||
}
|
||||
WizardStageContent(model: model, workflow: workflow)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.background(Theme.background)
|
||||
|
||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Verification") {
|
||||
HStack {
|
||||
Text("Good ΔE ≤")
|
||||
TextField(
|
||||
"2.0",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("Warning ΔE ≤")
|
||||
TextField(
|
||||
"5.0",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
}
|
||||
TextField(
|
||||
"Good ΔE ≤",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsDeltaEGood")
|
||||
|
||||
TextField(
|
||||
"Warning ΔE ≤",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsDeltaEWarning")
|
||||
|
||||
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(model.validationErrors, id: \.self) { error in
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
@@ -93,16 +95,12 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Calibration") {
|
||||
HStack {
|
||||
Text("Stale after")
|
||||
TextField(
|
||||
"30",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("days")
|
||||
}
|
||||
TextField(
|
||||
"Stale after (days)",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsCalStaleDays")
|
||||
}
|
||||
|
||||
Section("Profile install") {
|
||||
@@ -143,6 +141,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 45)
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -37,6 +37,21 @@ struct SidebarView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
presetBlock
|
||||
mediaBlock
|
||||
studioButtons
|
||||
stepperAndProject
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
|
||||
// children (#146); these Group blocks are layout-transparent, so
|
||||
// visual order, ids and the 270 pt column are unchanged.
|
||||
private var header: some View {
|
||||
Group {
|
||||
HStack {
|
||||
Image("ICCery-logo")
|
||||
.resizable()
|
||||
@@ -65,7 +80,11 @@ struct SidebarView: View {
|
||||
.padding(12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
}
|
||||
}
|
||||
|
||||
private var presetBlock: some View {
|
||||
Group {
|
||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||
// applies the preset immediately; names render via Text only.
|
||||
Picker("Preset", selection: Binding(
|
||||
@@ -97,7 +116,11 @@ struct SidebarView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaBlock: some View {
|
||||
Group {
|
||||
// Media library (`#mediaSelect`) — issue #146. Selection
|
||||
// applies the recipe immediately, like presets; names render
|
||||
// via Text only (#114). Never reuses `presetSelect` (#137).
|
||||
@@ -147,7 +170,11 @@ struct SidebarView: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var studioButtons: some View {
|
||||
Group {
|
||||
// Calibrate Printer (`#btnCalibratePrinter`).
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
@@ -187,7 +214,11 @@ struct SidebarView: View {
|
||||
showing: $showingAllHelp)
|
||||
.accessibilityIdentifier("btnSpotRead")
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var stepperAndProject: some View {
|
||||
Group {
|
||||
Divider().overlay(Theme.border)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -214,8 +245,6 @@ struct SidebarView: View {
|
||||
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||
workflow.print.selectedTray = nil
|
||||
workflow.print.selectedMediaType = nil
|
||||
workflow.print.selectedQuality = nil
|
||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.print.printers
|
||||
@@ -279,7 +280,9 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
// Tray / media / paper / quality / orientation — from
|
||||
// queue capabilities. Extracted subviews keep every
|
||||
// ViewBuilder ≤10 children (R13).
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.print.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||
@@ -301,6 +304,12 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("mediaTypeGroup")
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
if !workflow.print.printerCaps.paperSizes.isEmpty {
|
||||
paperSizeGroup
|
||||
}
|
||||
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||
qualityGroup
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -313,11 +322,13 @@ struct Stage2View: View {
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
// Stage 1 owns the custom dimensions — the caption lives
|
||||
// inside `paperSizeGroup` (#183).
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||
workflow.print.printAllPages(from: result)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
@@ -344,6 +355,50 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||
schedulePrinterRefresh()
|
||||
}
|
||||
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
|
||||
.onChange(of: workflow.pageSize) { _ in
|
||||
workflow.print.seedPaperSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||
private var paperSizeGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Paper", selection: $workflow.print.selectedPaperSize) {
|
||||
ForEach(workflow.print.printerCaps.paperSizes, id: \.id) { size in
|
||||
Text(size.id == 0
|
||||
? "Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)"
|
||||
: size.name)
|
||||
.tag(Optional(size.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerPaperSizeSelect")
|
||||
if workflow.print.selectedPaperSize == 0 {
|
||||
Text("Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("paperSizeGroup")
|
||||
}
|
||||
|
||||
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
||||
private var qualityGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerQualitySelect")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("qualityGroup")
|
||||
}
|
||||
|
||||
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||
@@ -392,7 +447,7 @@ private struct GalleryPageView: View {
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||
Button("Print") { workflow.print.printPage(page) }
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
|
||||
@@ -137,6 +137,9 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
environment: environment
|
||||
)
|
||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||
// Paper-size seeding reads the Stage 1 form through this weak
|
||||
// back-reference; the print side never writes it (#183).
|
||||
self.print.workflow = self
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
|
||||
@@ -102,6 +102,62 @@ final class TiffPreviewTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
final class AppInfoTests: XCTestCase {
|
||||
// MARK: displayVersion (#189)
|
||||
|
||||
func testNoTagFallsBackToShortVersion() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: ""),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testWhitespaceTagFallsBackToShortVersion() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: " "),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testExactReleaseTagDedupes() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "v2.0.0"),
|
||||
"v2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testBareVersionTagDedupes() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "2.0.0"),
|
||||
"2.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
func testPrereleaseTagShowsMarketingInParens() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(
|
||||
shortVersion: "2.0.0", releaseTag: "v2.0.0-pre2-grok"),
|
||||
"v2.0.0-pre2-grok (2.0.0)"
|
||||
)
|
||||
}
|
||||
|
||||
func testDescribeStringShowsMarketingInParens() {
|
||||
XCTAssertEqual(
|
||||
ArtefactFiles.displayVersion(
|
||||
shortVersion: "2.0.0", releaseTag: "v2.0.0-5-gdeadbee"),
|
||||
"v2.0.0-5-gdeadbee (2.0.0)"
|
||||
)
|
||||
}
|
||||
|
||||
func testAppInfoUsesComposedVersion() {
|
||||
// .main in the test host is the ICCery app under test; whatever it
|
||||
// resolves to, build must be a non-empty digits-or-default string.
|
||||
let info = ArtefactFiles.appInfo()
|
||||
XCTAssertFalse(info.version.isEmpty)
|
||||
XCTAssertFalse(info.build.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
final class ArtefactFilesTests: XCTestCase {
|
||||
func testBase64RoundTrip() throws {
|
||||
let url = tempURL("a.txt")
|
||||
|
||||
@@ -24,6 +24,18 @@ final class CupsOptionsFilterTests: XCTestCase {
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
}
|
||||
|
||||
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||
/// keys) survives the filter so it wins over the Stage 2 explicit
|
||||
/// quality in `LpArgs`.
|
||||
func testKeepsQualityKeys() {
|
||||
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best "
|
||||
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw),
|
||||
"EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best")
|
||||
}
|
||||
|
||||
func testKeepsUnknown() {
|
||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||
final class CupsParsersTests: XCTestCase {
|
||||
final class CupsParserTests: XCTestCase {
|
||||
|
||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||
private let lpstatE = """
|
||||
@@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase {
|
||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||
ColorModel/Output Mode: *RGB Gray
|
||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
"""
|
||||
|
||||
func testDestinations() {
|
||||
@@ -117,6 +117,157 @@ final class CupsParsersTests: XCTestCase {
|
||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #183 quality key + option extraction
|
||||
|
||||
func testQualityKeyRosterOrder() {
|
||||
// Vendor keys beat the generic ones; OutputMode/Resolution sit
|
||||
// last (they are colour-ish keys on some drivers — #183/#180).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
|
||||
// A full Epson key set — EPIJ_Qual wins over the colour-mode
|
||||
// key, the generic keys, and Resolution (#180, R11).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
|
||||
"cupsPrintQuality", "PrintQuality",
|
||||
"ColorModel"]), "EPIJ_Qual")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
|
||||
"CNIJQuality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Resolution"]), "Resolution")
|
||||
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
func testCapabilitiesQuality() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
// EPIJ_Qual is the roster member — all seven Epson codes
|
||||
// enumerate in the driver's own (non-sorted) order (#180).
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
func testCapabilitiesQualityPpdLabels() {
|
||||
// Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label`
|
||||
// lines in the driver's own order (#180).
|
||||
let ppd = """
|
||||
*OpenUI *EPIJ_Qual/Print Quality: PickOne
|
||||
*DefaultEPIJ_Qual: 303
|
||||
*EPIJ_Qual 301/Fast Economy: ""
|
||||
*EPIJ_Qual 302/Economy: ""
|
||||
*EPIJ_Qual 303/Normal: ""
|
||||
*EPIJ_Qual 308/Draft: ""
|
||||
*EPIJ_Qual 304/Fine: ""
|
||||
*EPIJ_Qual 305/Quality: ""
|
||||
*EPIJ_Qual 307/Best Quality: ""
|
||||
*CloseUI: *EPIJ_Qual
|
||||
"""
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
|
||||
let caps = service.capabilities(from: listings, ppd: ppd)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities, [
|
||||
PrinterQuality(id: "301", name: "Fast Economy"),
|
||||
PrinterQuality(id: "302", name: "Economy"),
|
||||
PrinterQuality(id: "303", name: "Normal"),
|
||||
PrinterQuality(id: "308", name: "Draft"),
|
||||
PrinterQuality(id: "304", name: "Fine"),
|
||||
PrinterQuality(id: "305", name: "Quality"),
|
||||
PrinterQuality(id: "307", name: "Best Quality"),
|
||||
])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
/// #180 — the Epson listing also carries `OutputMode` (a colour
|
||||
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
|
||||
func testCapabilitiesQualityEpsonDetection() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList("""
|
||||
PageSize/Media Size: *A4 Letter
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
OutputMode/Color Mode: *Color Mono
|
||||
Resolution/Resolution: *360dpi 720dpi
|
||||
""")
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.count, 7)
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
|
||||
}
|
||||
|
||||
func testExtractOption() {
|
||||
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: options), "A4")
|
||||
// Case-insensitive key match.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "epij_qual", fromOptionsString: options), "303")
|
||||
// Quoted values come back unquoted.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
|
||||
XCTAssertNil(CupsParsers.extractOption(
|
||||
named: "InputSlot", fromOptionsString: options))
|
||||
}
|
||||
|
||||
func testExtractQualityAndOrientation() {
|
||||
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
|
||||
XCTAssertEqual(CupsParsers.extractQuality(
|
||||
fromOptionsString: options), "305")
|
||||
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||
fromOptionsString: options), "landscape")
|
||||
XCTAssertNil(CupsParsers.extractQuality(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
XCTAssertNil(CupsParsers.extractOrientation(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
}
|
||||
|
||||
// MARK: - #186 capture-return
|
||||
|
||||
/// A captured `k=v` string maps to all four `PrintOptions`
|
||||
/// fields — `PageSize`, the detected quality key,
|
||||
/// `orientation-requested`, and a vendor media key (#186).
|
||||
func testCapturedStringMapsAllFields() {
|
||||
let captured =
|
||||
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: captured), "A4")
|
||||
XCTAssertEqual(CupsParsers.extractQuality(
|
||||
fromOptionsString: captured), "305")
|
||||
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||
fromOptionsString: captured), "landscape")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: captured), "Photo")
|
||||
}
|
||||
|
||||
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
|
||||
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
|
||||
/// (#186). `MediaType` still wins when present alongside them.
|
||||
func testExtractMediaTypeRosterFallback() {
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
|
||||
// `MediaType` keeps first precedence (docs/11 §tests).
|
||||
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
|
||||
"Plain")
|
||||
XCTAssertNil(CupsParsers.extractMediaType(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
}
|
||||
|
||||
func testDriverBypass() {
|
||||
func pair(_ keys: Set<String>) -> String? {
|
||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||
@@ -131,4 +282,177 @@ final class CupsParsersTests: XCTestCase {
|
||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||
XCTAssertNil(pair(["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||
|
||||
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||
/// are the numeric codes the driver enumerates via `lpoptions -l`.
|
||||
private let canonMedia: [(id: String, label: String)] = [
|
||||
("0", "Plain Paper"),
|
||||
("1", "Photo Paper Plus Glossy II"),
|
||||
("2", "Photo Paper Pro Platinum N"),
|
||||
("3", "Photo Paper Pro Platinum"),
|
||||
("4", "Photo Paper Pro Luster"),
|
||||
("5", "Photo Paper Plus Semi-gloss"),
|
||||
("6", "Matte Photo Paper"),
|
||||
("7", "Fine Art \"Photo Rag\""),
|
||||
("8", "Fine Art \"Museum Etching\""),
|
||||
("9", "Photo Paper Pro Premium Matte"),
|
||||
("10", "Fine Art Premium Matte"),
|
||||
("11", "Other Fine Art Paper"),
|
||||
("12", "Canvas"),
|
||||
("13", "Board Paper"),
|
||||
("14", "Ink Jet Hagaki"),
|
||||
("15", "Hagaki"),
|
||||
("16", "Printable disc"),
|
||||
("17", "Printable disc (bleed-proof)"),
|
||||
]
|
||||
|
||||
/// Canon Pro9500-shaped fragment: the unqualified base block comes
|
||||
/// early and the `th.` block trails at the end — the ordering that
|
||||
/// let Thai overwrite English under last-write-wins (#181).
|
||||
private var canonPPD: String {
|
||||
var lines = [
|
||||
"*OpenUI *CNIJMediaType/Media Type: PickOne",
|
||||
"*DefaultCNIJMediaType: 0",
|
||||
]
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
|
||||
}
|
||||
lines.append("*CloseUI: *CNIJMediaType")
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
|
||||
let labels = CupsParsers.ppdChoiceLabels(
|
||||
canonPPD, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
|
||||
// `th.` block first — precedence is deterministic, not
|
||||
// positional (#181, E3).
|
||||
let ppd = """
|
||||
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
|
||||
*CNIJMediaType 0/Plain Paper: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
}
|
||||
|
||||
func testPpdLabelsQualifiedFallbackOrder() {
|
||||
// en_US > en > first-qualified-seen (#181, E3).
|
||||
let ppd = """
|
||||
*en.CNIJMediaType 1/English Label: ""
|
||||
*en_US.CNIJMediaType 1/US English Label: ""
|
||||
*th.CNIJMediaType 1/กระดาษ: ""
|
||||
*fr.CNIJMediaType 2/Français: ""
|
||||
*de.CNIJMediaType 2/Deutsch: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["1"], "US English Label")
|
||||
// A qualified-only id still gets its first-seen qualified
|
||||
// label — never left unlabeled (R9).
|
||||
XCTAssertEqual(labels["2"], "Français")
|
||||
}
|
||||
|
||||
func testPpdLabelsHexEscapeDecoding() {
|
||||
let ppd = """
|
||||
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
|
||||
*CNIJMediaType 4/Plain<20>Paper: ""
|
||||
*CNIJMediaType 5/Bad<ZZ>Escape: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||
}
|
||||
|
||||
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
|
||||
/// non-Thai label (E1 — the true count is the hardware gate's, so
|
||||
/// no count is hardcoded here); the 18 named AC labels are
|
||||
/// spot-checked.
|
||||
func testCapabilitiesCanonMediaAllNonThai() {
|
||||
var choices = canonMedia.map(\.id)
|
||||
choices[0] = "*\(choices[0])"
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
|
||||
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
|
||||
|
||||
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
|
||||
for type in caps.mediaTypes {
|
||||
XCTAssertFalse(type.name.unicodeScalars.contains {
|
||||
(0x0E00...0x0E7F).contains($0.value)
|
||||
}, "Thai label leaked into \(type.id): \(type.name)")
|
||||
}
|
||||
for media in canonMedia {
|
||||
XCTAssertEqual(
|
||||
caps.mediaTypes.first { $0.id == media.id }?.name,
|
||||
media.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 PPD carrying Thai labels decodes intact — the English
|
||||
/// base block wins precedence and no mojibake leaks through (#181,
|
||||
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
|
||||
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: Data(canonPPD.utf8),
|
||||
listing: "CNIJMediaType/Media Type: *0 1")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes.map(\.name),
|
||||
["Plain Paper", "Photo Paper Plus Glossy II"])
|
||||
}
|
||||
|
||||
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
|
||||
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
|
||||
func testLoadPPDLatin1Fallback() async throws {
|
||||
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: ppd.data(using: .isoLatin1)!,
|
||||
listing: "CNIJMediaType/Media Type: *0")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes,
|
||||
[PrinterMediaType(id: "0", name: "Papier Couché")])
|
||||
}
|
||||
|
||||
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
|
||||
/// the private `loadPPD` — same mock style as
|
||||
/// `MediaLibraryViewModelTests.installMockCups`.
|
||||
private func makeCupsService(
|
||||
ppdData: Data,
|
||||
listing: String,
|
||||
queue: String = "Canon_Test"
|
||||
) throws -> (CupsService, URL) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
|
||||
let bin = root.appendingPathComponent("bin")
|
||||
let ppdDir = root.appendingPathComponent("ppd")
|
||||
for dir in [bin, ppdDir] {
|
||||
try FileManager.default.createDirectory(
|
||||
at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
printf '%s\\n' '\(listing)'
|
||||
"""
|
||||
let scriptURL = bin.appendingPathComponent("lpoptions")
|
||||
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
|
||||
return (CupsService(
|
||||
processManager: ProcessManager(),
|
||||
binaryDir: bin, ppdDir: ppdDir), root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
|
||||
}
|
||||
|
||||
/// #147 — `viewerUnavailable` is decided before any `SCNView` is
|
||||
/// mounted: it must exactly mirror Metal presence on this host.
|
||||
func testViewerUnavailableMirrorsMetalAvailability() async throws {
|
||||
let vm = try makeViewModel()
|
||||
XCTAssertEqual(
|
||||
vm.viewerUnavailable,
|
||||
MTLCreateSystemDefaultDevice() == nil)
|
||||
}
|
||||
|
||||
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
|
||||
let vm = try makeViewModel()
|
||||
await vm.awaitInitialLoad()
|
||||
|
||||
@@ -101,6 +101,61 @@ final class LpArgsTests: XCTestCase {
|
||||
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
|
||||
}
|
||||
|
||||
// MARK: - #183 print quality
|
||||
|
||||
func testQualityDerived() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
orientation: "portrait", mediaType: "Photo", quality: "305"),
|
||||
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"))
|
||||
// Emit order: after the media option, before orientation.
|
||||
let media = argv.firstIndex(of: "MediaType=Photo")!
|
||||
let quality = argv.firstIndex(of: "EPIJ_Qual=305")!
|
||||
let orient = argv.firstIndex(of: "orientation-requested=3")!
|
||||
XCTAssertTrue(media < quality && quality < orient)
|
||||
}
|
||||
|
||||
func testQualityDetectedKey() throws {
|
||||
// The detected queue key is used, not a hardcoded one.
|
||||
let argv = try build(
|
||||
options: PrintOptions(quality: "High"),
|
||||
optionKeys: ["cupsPrintQuality"])
|
||||
XCTAssertTrue(argv.contains("cupsPrintQuality=High"))
|
||||
}
|
||||
|
||||
func testCapturedWinsQuality() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
quality: "303",
|
||||
cupsOptions: "EPIJ_Qual=308"),
|
||||
optionKeys: ["EPIJ_Qual"])
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=308"))
|
||||
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||
}
|
||||
|
||||
func testCapturedQualityCaseInsensitiveDedup() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
quality: "303",
|
||||
cupsOptions: "epij_qual=308"),
|
||||
optionKeys: ["EPIJ_Qual"])
|
||||
XCTAssertFalse(argv.contains("EPIJ_Qual=303"))
|
||||
XCTAssertTrue(argv.contains("epij_qual=308"))
|
||||
}
|
||||
|
||||
func testQualityNilNoEmit() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(mediaType: "Photo"),
|
||||
optionKeys: ["EPIJ_Qual", "MediaType"])
|
||||
XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||
// No quality key on the queue → no emit either.
|
||||
let noKey = try build(
|
||||
options: PrintOptions(quality: "303"),
|
||||
optionKeys: ["MediaType"])
|
||||
XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") })
|
||||
}
|
||||
|
||||
func testSanitise() throws {
|
||||
XCTAssertThrowsError(try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
|
||||
|
||||
@@ -52,6 +52,41 @@ final class PrintPanelStubTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// #183 — the stub parses paper size / quality / orientation out of
|
||||
/// `ICCERY_TEST_PANEL_OPTIONS` so UI tests can verify apply-back.
|
||||
func testOkResultExtractsNewFields() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS":
|
||||
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4",
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
XCTAssertEqual(result?.options.paperSize, "Letter")
|
||||
XCTAssertEqual(result?.options.quality, "305")
|
||||
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||
}
|
||||
}
|
||||
|
||||
/// #186 — a vendor media key in the captured string reaches
|
||||
/// `options.mediaType` through the detection roster
|
||||
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
|
||||
func testOkResultExtractsVendorMediaKey() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS":
|
||||
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
XCTAssertEqual(result?.options.paperSize, "A4")
|
||||
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||
}
|
||||
}
|
||||
|
||||
func testOkDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
|
||||
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
|
||||
/// triggers, and `PrintOptions` wiring into `lp` argv.
|
||||
/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no
|
||||
/// live CUPS is touched.
|
||||
@MainActor
|
||||
final class PrintSessionViewModelTests: XCTestCase {
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
lpArgvURL = env.root.appendingPathComponent("lp-argv.log")
|
||||
try writeCupsFixtures()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
lpArgvURL = nil
|
||||
}
|
||||
|
||||
private var binDir: URL {
|
||||
env.root.appendingPathComponent("cups-bin")
|
||||
}
|
||||
|
||||
/// Mock `lpoptions -l` advertises paper sizes + a quality key;
|
||||
/// mock `lp` appends its argv to `lpArgvURL` for assertions.
|
||||
private func writeCupsFixtures() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: binDir, withIntermediateDirectories: true)
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
list=0
|
||||
queue=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l) list=1 ;;
|
||||
-*) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\\n'
|
||||
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||
exit 0
|
||||
"""
|
||||
let lp = """
|
||||
#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "\(lpArgvURL.path)"
|
||||
exit 0
|
||||
"""
|
||||
for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] {
|
||||
let url = binDir.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWorkflow() -> TargetWorkflowViewModel {
|
||||
TargetWorkflowViewModel(environment: env.environment)
|
||||
}
|
||||
|
||||
private func loadCaps(
|
||||
_ vm: PrintSessionViewModel, queue: String = "Mock_Q"
|
||||
) async {
|
||||
vm.selectedPrinter = queue
|
||||
await vm.reloadSelectedCapabilities()
|
||||
}
|
||||
|
||||
private func waitForFile(
|
||||
_ url: URL, timeout: TimeInterval = 10
|
||||
) async -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
!text.isEmpty {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Seeding / mirror
|
||||
|
||||
/// `pageSize = .a4` matches the capability named "A4" → its id.
|
||||
func testSeedMatchesPageSizeRawValue() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.pageSize, .a4)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
// Quality seeds from the `*` default on caps load.
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
// All seven Epson codes enumerate in driver order (#180).
|
||||
XCTAssertEqual(workflow.print.printerCaps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
}
|
||||
|
||||
/// `.custom` → synthetic `id: 0` entry whose token is the
|
||||
/// dimensions in **points** (mm × 72/25.4): 210×297 → `Custom.595x842`.
|
||||
func testSeedCustomPageSizeSyntheticEntry() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .custom
|
||||
workflow.customPageW = 210
|
||||
workflow.customPageH = 297
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 0)
|
||||
let synthetic = workflow.print.printerCaps.paperSizes
|
||||
.first { $0.id == 0 }
|
||||
XCTAssertEqual(synthetic?.name, "Custom.595x842")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSizeToken, "Custom.595x842")
|
||||
}
|
||||
|
||||
/// A `workflow.pageSize` change re-mirrors the picker.
|
||||
func testReseedOnPageSizeChange() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
|
||||
workflow.pageSize = .letter
|
||||
workflow.print.seedPaperSelection()
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
}
|
||||
|
||||
/// A user pick survives unrelated publishes — re-seed only fires
|
||||
/// on pageSize / printer / caps triggers (#183, R14).
|
||||
func testUserEditPreservedAcrossUnrelatedPublishes() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
|
||||
workflow.print.selectedTray = 2
|
||||
workflow.print.printOrientation = "landscape"
|
||||
workflow.print.printNotice = Notice(kind: .info, text: "x")
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 5)
|
||||
}
|
||||
|
||||
/// A printer change re-seeds from `workflow.pageSize` after the
|
||||
/// capabilities reload (the picker re-mirrors, not guesses).
|
||||
func testPrinterChangeReseeds() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
workflow.print.selectedQuality = "301"
|
||||
|
||||
// The view nils quality on printer change before reloading —
|
||||
// the VM re-seeds `when nil` only (#183 contract).
|
||||
workflow.print.selectedPrinter = "Other_Q"
|
||||
workflow.print.selectedQuality = nil
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// A pageSize with no capability match leaves the pick nil —
|
||||
/// never a guessed id.
|
||||
func testSeedNoMatchLeavesNil() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a2
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertNil(workflow.print.selectedPaperSize)
|
||||
XCTAssertNil(workflow.print.selectedPaperSizeToken)
|
||||
}
|
||||
|
||||
// MARK: - Spool wiring
|
||||
|
||||
/// `spool` emits the Stage 2 paper token and quality through
|
||||
/// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o <qualityKey>=`).
|
||||
func testSpoolPassesPaperTokenAndQuality() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 4 // Letter
|
||||
workflow.print.selectedQuality = "301"
|
||||
|
||||
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||
try Data([0x49, 0x49]).write(to: tiff)
|
||||
let page = GalleryPage(
|
||||
index: 0,
|
||||
page: PrinttargPage(
|
||||
filename: "page1.tif", patches: 10,
|
||||
widthMm: 210, heightMm: 297),
|
||||
fileURL: tiff, previewPNG: nil, previewError: nil)
|
||||
let result = PrinttargResult(
|
||||
ti2URL: env.root.appendingPathComponent("target.ti2"),
|
||||
manifest: PrinttargManifest(pages: [page.page]),
|
||||
pages: [page])
|
||||
workflow.print.printAllPages(from: result)
|
||||
|
||||
let argv = await waitForFile(lpArgvURL)
|
||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv)
|
||||
}
|
||||
|
||||
// MARK: - Panel apply-back (stubbed NSPrintPanel)
|
||||
|
||||
/// The stubbed panel's captured `PageSize=`/`EPIJ_Qual=` apply back
|
||||
/// to `selectedPaperSize`/`selectedQuality` (#183 capture-return).
|
||||
func testPanelResultAppliesBackSelections() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||
"PageSize=Letter EPIJ_Qual=305", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline,
|
||||
workflow.print.selectedPaperSize != 4
|
||||
|| workflow.print.selectedQuality != "305" {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
}
|
||||
|
||||
/// #186 — the captured `orientation-requested`/media token apply
|
||||
/// back to `printOrientation`/`selectedMediaType`, and a dialog
|
||||
/// result never mutates `workflow.pageSize` (printtarg layout).
|
||||
func testPanelResultAppliesBackOrientationAndMedia() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||
"orientation-requested=4 MediaType=Glossy", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.printOrientation, "portrait")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
|
||||
XCTAssertEqual(workflow.pageSize, .a4)
|
||||
}
|
||||
|
||||
/// #186 — a captured `PageSize` token with no capability match
|
||||
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
||||
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
}
|
||||
|
||||
/// #186 — a stub result pointing at another queue in `printers`
|
||||
/// switches `selectedPrinter` and reloads its capabilities.
|
||||
func testPanelResultSwitchesToKnownQueue() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
|
||||
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
workflow.print.printers = [
|
||||
Printer(name: "Mock_Q", isDefault: true),
|
||||
Printer(name: "Other_Q"),
|
||||
]
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(
|
||||
workflow.print, containing: "Settings captured for Other_Q")
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
|
||||
// Caps reloaded for the new queue: paper re-seeded, then the
|
||||
// captured PageSize applied back onto the new caps.
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
|
||||
"PageSize=Letter")
|
||||
}
|
||||
|
||||
/// #186 — a stub result naming a queue absent from `printers`
|
||||
/// leaves the selection on the opened queue.
|
||||
func testPanelResultGhostQueueIgnored() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(
|
||||
workflow.print, containing: "Settings captured for Mock_Q")
|
||||
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
|
||||
}
|
||||
|
||||
/// #186 — cancel returns `nil`: info notice, no field changes.
|
||||
func testPanelCancelLeavesSelections() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.printOrientation = "landscape"
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "cancelled")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||
}
|
||||
|
||||
/// Poll until the panel task posts a notice whose text contains
|
||||
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||
private func waitForNotice(
|
||||
_ vm: PrintSessionViewModel,
|
||||
containing fragment: String,
|
||||
timeout: TimeInterval = 10
|
||||
) async {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let text = vm.printNotice?.text, text.contains(fragment) {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
XCTFail("Timed out waiting for notice containing '\(fragment)'")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@ final class AboutHelpUITests: XCTestCase {
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||
if inApp.exists { return inApp }
|
||||
// Search all sheets (including nested sheets) for the element.
|
||||
// The license window is a nested sheet (sheet presented from AboutView).
|
||||
for sheet in app.sheets.allElementsBoundByIndex {
|
||||
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
|
||||
if inSheet.exists { return inSheet }
|
||||
}
|
||||
// Fallback to original behavior
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||
}
|
||||
|
||||
@@ -85,4 +92,139 @@ final class AboutHelpUITests: XCTestCase {
|
||||
"Toggling global help must not reflow the sidebar.")
|
||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||
}
|
||||
|
||||
func testAboutDialogShowsViewLicensesButton() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
_ = waitFor("aboutVersion", timeout: 10)
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
XCTAssertTrue(viewLicensesBtn.exists)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
// License window should open as a sheet
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
XCTAssertTrue(element("licenseWindow").exists)
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
// License window should be dismissed
|
||||
XCTAssertFalse(element("licenseWindow").exists)
|
||||
|
||||
// Close about dialog
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
XCTAssertFalse(element("aboutDialog").exists)
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsICCeryLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ICCery license section exists
|
||||
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(icceryLicenseSection.exists)
|
||||
|
||||
// Verify ICCery license content contains key phrases
|
||||
let icceryLicenseContent = element("icceryLicenseSectionContent")
|
||||
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = icceryLicenseContent.value as? String ?? ""
|
||||
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
|
||||
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsArgyllLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ArgyllCMS license section exists
|
||||
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(argyllLicenseSection.exists)
|
||||
|
||||
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
|
||||
let argyllLicenseContent = element("argyllLicenseSectionContent")
|
||||
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = argyllLicenseContent.value as? String ?? ""
|
||||
// Should contain either the actual AGPLv3 license or the fallback notice
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsAttributionLinks() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify attribution section exists
|
||||
let attributionHeader = waitFor("attributionHeader", timeout: 5)
|
||||
XCTAssertTrue(attributionHeader.exists)
|
||||
|
||||
// Verify upstream link
|
||||
let upstreamLink = element("argyllUpstreamLink")
|
||||
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
|
||||
let upstreamLabel = upstreamLink.label
|
||||
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
|
||||
|
||||
// Verify fork link
|
||||
let forkLink = element("argyllForkLink")
|
||||
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
|
||||
let forkLabel = forkLink.label
|
||||
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
|
||||
|
||||
// Verify AGPL isolation note
|
||||
let isolationNote = element("agplIsolationNote")
|
||||
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
|
||||
let noteText = isolationNote.value as? String ?? ""
|
||||
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys and the
|
||||
# recorded XP-55 EPIJ_Qual line — the driver's own non-sorted order
|
||||
# (308 sits between 303 and 304, #180).
|
||||
queue=""
|
||||
list=0
|
||||
for arg in "$@"; do
|
||||
@@ -15,6 +17,7 @@ if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
|
||||
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||
exit 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
/// Milestone 10 — Issue #147 gamut compare chrome tests.
|
||||
@@ -9,6 +10,11 @@ import XCTest
|
||||
@MainActor
|
||||
final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
/// Metal on the test host — the app under test runs on the same
|
||||
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||
/// GPU-less runners still get the banner/Close assertions (#147).
|
||||
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||
if app != nil, element("btnCloseGamut").exists {
|
||||
element("btnCloseGamut").click()
|
||||
}
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
@@ -75,6 +85,22 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Exists **and** `isEnabled`. Layer toggles render as disabled
|
||||
/// placeholders until the async layer load lands — on the macOS 12
|
||||
/// runner `waitFor` alone wins the race against `parse`.
|
||||
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists && el.isEnabled { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(
|
||||
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
private func launchApp() {
|
||||
app.launch()
|
||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||
@@ -88,18 +114,41 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
_ = waitFor("gamutView")
|
||||
}
|
||||
|
||||
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !element(id).exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||
private func closeGamutSheet() {
|
||||
let close = element("btnCloseGamut")
|
||||
guard close.waitForExistence(timeout: 5) else { return }
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
func testLayerTogglesExistWithSRGB() throws {
|
||||
openGamutSheet()
|
||||
|
||||
let srgb = waitFor("gamutLayer-sRGB")
|
||||
let srgb = waitUntilEnabled("gamutLayer-sRGB")
|
||||
XCTAssertTrue(srgb.exists)
|
||||
XCTAssertTrue(srgb.isEnabled)
|
||||
// NSButton checkbox value is 1 when checked.
|
||||
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
|
||||
|
||||
let compare = waitFor("gamutLayer-compare")
|
||||
XCTAssertTrue(compare.exists)
|
||||
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
|
||||
|
||||
let status = waitFor("gamutStatusText")
|
||||
let statusValue = status.value as? String ?? ""
|
||||
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testAddCompareButtonExists() throws {
|
||||
@@ -113,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
let status = waitFor("gamutStatusText")
|
||||
let value = status.value as? String ?? ""
|
||||
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testCompareGamLoadEnablesToggle() throws {
|
||||
@@ -124,8 +174,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
waitFor("btnGamutAddCompare").click()
|
||||
waitFor("btnGamutOpenGam").click()
|
||||
|
||||
let compare = waitFor("gamutLayer-compare")
|
||||
// The pre-load placeholder also exists — wait for enabled.
|
||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
|
||||
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
|
||||
|
||||
let status = waitFor("gamutStatusText")
|
||||
let value = status.value as? String ?? ""
|
||||
@@ -133,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
|
||||
let remove = waitFor("btnGamutRemoveCompare")
|
||||
XCTAssertTrue(remove.isEnabled)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testOpenProfileRunsIccgamutForCompare() throws {
|
||||
@@ -146,8 +199,15 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
waitFor("btnGamutAddCompare").click()
|
||||
waitFor("btnGamutOpenProfile").click()
|
||||
|
||||
let compare = waitFor("gamutLayer-compare")
|
||||
let compare = waitUntilEnabled("gamutLayer-compare")
|
||||
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
|
||||
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
|
||||
|
||||
// The compare slot's display name is the .gam stem ("myprinter").
|
||||
let status = waitFor("gamutStatusText")
|
||||
let statusValue = status.value as? String ?? ""
|
||||
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testInspectPanelIdleStableHeight() throws {
|
||||
@@ -157,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
XCTAssertTrue(panel.exists)
|
||||
XCTAssertTrue(element("gamutInspectIdle").exists)
|
||||
XCTAssertTrue(element("gamutStatusText").exists)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testManualLabInspectShowsContainment() throws {
|
||||
@@ -176,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
|
||||
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
|
||||
XCTAssertTrue(element("gamutInspectL").exists)
|
||||
XCTAssertTrue(element("gamutInspectSwatch").exists)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
func testResetIdentifierUnchanged() throws {
|
||||
openGamutSheet()
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
XCTAssertTrue(reset.isEnabled)
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` is always enabled — including on the fallback
|
||||
/// banner — and dismisses the sheet (#147).
|
||||
func testCloseButtonDismissesSheet() throws {
|
||||
openGamutSheet()
|
||||
|
||||
let close = waitFor("btnCloseGamut")
|
||||
XCTAssertTrue(close.isEnabled)
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
/// The fallback banner exists exactly when the host lacks Metal —
|
||||
/// no `SCNView` is mounted on a GPU-less runner, and none may be
|
||||
/// reported unavailable on a GPU host.
|
||||
func testFallbackBannerMatchesGPUAvailability() throws {
|
||||
openGamutSheet()
|
||||
|
||||
if hasGPU {
|
||||
XCTAssertFalse(
|
||||
element("gamutViewerUnavailable").exists,
|
||||
"GPU host must mount the SceneKit view, not the fallback")
|
||||
} else {
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
}
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
|
||||
/// host — banner plus a working Close, no `SCNView` mounted (#147).
|
||||
/// The env is set for this test only; the default launch env must
|
||||
/// not carry it, or CI's future GPU run would skip SceneKit too.
|
||||
func testForcedSceneKitSkipShowsBannerAndClose() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
|
||||
openGamutSheet()
|
||||
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
let close = waitFor("btnCloseGamut")
|
||||
XCTAssertTrue(close.isEnabled)
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,9 @@ final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||
apply.click()
|
||||
|
||||
let notice = waitFor("noticeText")
|
||||
// The window banner (`noticeText`) sits behind this sheet on
|
||||
// Monterey (#170). Assert the in-sheet copy instead.
|
||||
let notice = waitFor("manageMediaNotice", timeout: 15)
|
||||
let text = (notice.value as? String) ?? notice.label
|
||||
XCTAssertTrue(
|
||||
text.contains("is not installed"),
|
||||
|
||||
@@ -3,8 +3,9 @@ import XCTest
|
||||
/// Milestone 10 UI tests — issue #149 project file. Panels are never
|
||||
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
|
||||
/// inject fixture paths through `UITestHooks`. Menu commands are driven
|
||||
/// by their keyboard shortcuts (⌘N) or the sidebar chip so the tests do
|
||||
/// not depend on menu AX exposure (R19). All queries by identifier.
|
||||
/// through the File menu when it is in the AX tree, else by their
|
||||
/// keyboard shortcuts (⌘N) — the tests do not depend on menu AX
|
||||
/// exposure (R19). All queries by identifier.
|
||||
@MainActor
|
||||
final class Milestone10ProjectUITests: XCTestCase {
|
||||
|
||||
@@ -77,6 +78,39 @@ final class Milestone10ProjectUITests: XCTestCase {
|
||||
return (el.value as? String) ?? el.label
|
||||
}
|
||||
|
||||
/// Alert/sheet button by visible title, falling back to the a11y
|
||||
/// id; nil when neither matches. macOS 12 SwiftUI alerts often
|
||||
/// drop `accessibilityIdentifier` on their buttons, so the title
|
||||
/// is the reliable handle there.
|
||||
private func alertButton(title: String, id: String) -> XCUIElement? {
|
||||
let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch
|
||||
if inDialog.exists { return inDialog }
|
||||
let inSheet = app.sheets.firstMatch.buttons[title].firstMatch
|
||||
if inSheet.exists { return inSheet }
|
||||
let byId = element(id)
|
||||
return byId.exists ? byId : nil
|
||||
}
|
||||
|
||||
/// Fires File ▸ New Project via the menu when it is in the AX
|
||||
/// tree, else ⌘N. On macOS 12 `typeKey` may not reach the
|
||||
/// `CommandGroup`, and menu item ids are unreliable — the menu
|
||||
/// item is matched by its "New Project" label first.
|
||||
private func triggerNewProject() {
|
||||
let fileMenu = app.menuBarItems["File"]
|
||||
if fileMenu.waitForExistence(timeout: 5) {
|
||||
fileMenu.click()
|
||||
let byTitle = app.menuItems["New Project"].firstMatch
|
||||
let byId = app.menuItems["menuProjectNew"].firstMatch
|
||||
let item = byTitle.exists ? byTitle : byId
|
||||
if item.waitForExistence(timeout: 5) {
|
||||
item.click()
|
||||
return
|
||||
}
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
}
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
}
|
||||
|
||||
/// Writes a `.icceryproj` fixture under `testRoot` and points the
|
||||
/// open-picker hook at it.
|
||||
private func stageProjectFixture(
|
||||
@@ -133,23 +167,26 @@ final class Milestone10ProjectUITests: XCTestCase {
|
||||
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
|
||||
XCTAssertEqual(basenameField.value as? String, "ui149job")
|
||||
|
||||
// ⌘N fires the File-menu New command even when the menu is not
|
||||
// in the AX tree. Mock CUPS may have enumerated a queue that the
|
||||
// fixture does not record, making the session dirty — in that
|
||||
// case the dirty alert gates New first.
|
||||
app.typeKey("n", modifierFlags: .command)
|
||||
// File ▸ New Project when the menu is in the AX tree, else
|
||||
// ⌘N. Mock CUPS may have enumerated a queue that the fixture
|
||||
// does not record, making the session dirty — in that case
|
||||
// the dirty alert gates New first. macOS 12 alerts often lack
|
||||
// button identifiers, so confirm by title with id fallback.
|
||||
triggerNewProject()
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
var confirmed = false
|
||||
while Date() < deadline {
|
||||
// Dirty sessions show the dirty alert first; discarding it
|
||||
// runs the New reset directly (no second confirm).
|
||||
if element("btnProjectDirtyDiscard").exists {
|
||||
element("btnProjectDirtyDiscard").click()
|
||||
if let discard = alertButton(
|
||||
title: "Don't Save", id: "btnProjectDirtyDiscard") {
|
||||
discard.click()
|
||||
confirmed = true
|
||||
break
|
||||
}
|
||||
if element("btnProjectNewConfirm").exists {
|
||||
element("btnProjectNewConfirm").click()
|
||||
if let start = alertButton(
|
||||
title: "Start", id: "btnProjectNewConfirm") {
|
||||
start.click()
|
||||
confirmed = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 11 UI tests — issue #183 Stage 2 paper size + print
|
||||
/// quality pickers. Runs against the same mock CUPS fixture binaries
|
||||
/// as `Milestone3UITests`; the `NSPrintPanel` stays stubbed through
|
||||
/// `ICCERY_TEST_PRINT_PANEL` (XCUITest cannot drive the system modal).
|
||||
@MainActor
|
||||
final class Milestone11PrintSettingsUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui11-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
/// Drive the app through targen + printtarg so the print panel is
|
||||
/// live with a manifest.
|
||||
private func reachPrintPanel() {
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
_ = waitFor("galleryPage-0", timeout: 25)
|
||||
}
|
||||
|
||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
if !out.isEmpty { return out }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// The paper-size and quality pickers exist with their new ids;
|
||||
/// the existing tray / media / orientation ids are unchanged (#183).
|
||||
func testPaperAndQualityPickersExist() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
XCTAssertTrue(element("printerPaperSizeSelect").exists)
|
||||
XCTAssertTrue(element("printerQualitySelect").exists)
|
||||
XCTAssertTrue(element("paperSizeGroup").exists)
|
||||
XCTAssertTrue(element("qualityGroup").exists)
|
||||
|
||||
// Existing ids untouched. (`mediaTypeGroup` is not asserted —
|
||||
// stacked `.accessibilityIdentifier` modifiers collapse to the
|
||||
// last one, so it never resolved even before this change.)
|
||||
XCTAssertTrue(element("printerSelect").exists)
|
||||
XCTAssertTrue(element("printerTraySelect").exists)
|
||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
||||
XCTAssertTrue(element("btnPrinterProperties").exists)
|
||||
}
|
||||
|
||||
/// The displayed selection of a picker — `AXTitle` for a popup
|
||||
/// button, falling back to label/value depending on how AppKit
|
||||
/// exposes the current item.
|
||||
private func selection(of id: String) -> String {
|
||||
let el = element(id)
|
||||
for candidate in [el.title, el.label, el.value as? String ?? ""] {
|
||||
if !candidate.isEmpty, candidate != el.identifier {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return el.title
|
||||
}
|
||||
|
||||
/// The paper picker seeds from Stage 1's `pageSize` (A4 default)
|
||||
/// and the quality picker from the driver's `*` default choice.
|
||||
func testPickersSeedFromStage1AndDriverDefault() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
_ = waitFor("printerPaperSizeSelect")
|
||||
_ = waitFor("printerQualitySelect")
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "A4")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "303")
|
||||
}
|
||||
|
||||
/// #180 — the quality picker lists all seven Epson `EPIJ_Qual`
|
||||
/// codes in the driver's own order (308 between 303 and 304); no
|
||||
/// PPD is injected under UI testing so items show raw tokens.
|
||||
func testQualityPickerListsAllSevenDriverOptions() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
let picker = app.popUpButtons["printerQualitySelect"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||
picker.click()
|
||||
|
||||
let expected = ["301", "302", "303", "308", "304", "305", "307"]
|
||||
for token in expected {
|
||||
XCTAssertTrue(
|
||||
app.menuItems[token].waitForExistence(timeout: 5),
|
||||
"Missing quality menu item \(token)")
|
||||
}
|
||||
let titles = app.menuItems.allElementsBoundByIndex
|
||||
.map(\.title)
|
||||
.filter { expected.contains($0) }
|
||||
XCTAssertEqual(titles, expected)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
}
|
||||
|
||||
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
|
||||
/// apply back into the Stage 2 pickers and reach the `lp` argv
|
||||
/// (R15 — the real modal is never driven).
|
||||
func testPanelResultAppliesBackToPickers() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"PageSize=Letter EPIJ_Qual=305"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||
}
|
||||
|
||||
/// #186 — the stubbed panel result's `orientation-requested=` /
|
||||
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
||||
/// `lp` argv through the captured `cupsOptions` replay.
|
||||
func testPanelResultAppliesBackOrientationAndMedia() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
// `printerMediaTypeSelect` is the group's id — the popup is a
|
||||
// descendant (stacked identifiers collapse to the container).
|
||||
// The popup's AX title lags the binding — poll for the
|
||||
// apply-back value.
|
||||
let mediaPopup = element("printerMediaTypeSelect")
|
||||
.descendants(matching: .popUpButton).firstMatch
|
||||
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||
var mediaSelection = ""
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
|
||||
mediaSelection = [
|
||||
mediaPopup.title, mediaPopup.label,
|
||||
mediaPopup.value as? String ?? "",
|
||||
].first { !$0.isEmpty } ?? ""
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("orientation-requested=4"), argv)
|
||||
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||
}
|
||||
|
||||
private func launchAppWithDefaults() {
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ final class Milestone2UITests: XCTestCase {
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
@@ -85,6 +86,31 @@ final class Milestone2UITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Exists **and** `isEnabled` — guards clicks against buttons that
|
||||
/// appear a beat before their `.disabled` condition clears.
|
||||
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists && el.isEnabled { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(
|
||||
el.exists && el.isEnabled, "Expected enabled element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
/// Non-asserting existence poll for the retry-or-fail pattern.
|
||||
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element(id).exists { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return element(id).exists
|
||||
}
|
||||
|
||||
/// Assert an element stays absent after a short dwell — unlike
|
||||
/// `waitForExistence`, which always burns its full timeout on the
|
||||
/// negative path.
|
||||
@@ -181,16 +207,24 @@ final class Milestone2UITests: XCTestCase {
|
||||
XCTAssertTrue(element("tiffDpi").exists)
|
||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
||||
// On the slow macOS 12 runner a synthesized click can land
|
||||
// while the button is still rebuilding — retry once if the
|
||||
// gallery never materialises, then allow a generous window
|
||||
// for the fixture printtarg + PNG render.
|
||||
waitUntilEnabled("btnCreateLayout").click()
|
||||
if !existsAfter("galleryPage-0", timeout: 15) {
|
||||
waitUntilEnabled("btnCreateLayout").click()
|
||||
}
|
||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
|
||||
// Print panel is live from M3; a default printer is selected
|
||||
// so both the all-pages and per-page print buttons are enabled.
|
||||
// Print panel is live from M3. GitHub macos-14 has no system
|
||||
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||
// before Print All / per-page enable (run 34867767434).
|
||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
||||
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
|
||||
return recordedLpArgv()
|
||||
}
|
||||
|
||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||
/// moves up, clear of the Dock collision zone at the window's
|
||||
/// bottom edge (#132).
|
||||
///
|
||||
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||
/// point that can be a Dock icon — a coordinate click there once
|
||||
/// opened Calendar instead of Print. Callers must click only when
|
||||
/// the returned element `isHittable`; never coordinate-click a
|
||||
/// stale frame.
|
||||
@discardableResult
|
||||
private func scrollStage2UntilHittable(
|
||||
_ identifier: String,
|
||||
timeout: TimeInterval = 20
|
||||
) -> XCUIElement {
|
||||
var button = app.buttons[identifier]
|
||||
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let windowBottom = app.windows.firstMatch.frame.maxY
|
||||
if button.exists, button.isHittable,
|
||||
button.frame.maxY < windowBottom - 80 {
|
||||
return button
|
||||
}
|
||||
// Grab the upper half of the cell (the TIFF, not the Print
|
||||
// button / Dock) and drag toward the top of the window.
|
||||
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||
// the Dock zone.
|
||||
if cell.isHittable {
|
||||
let start = cell.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.25))
|
||||
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||
start.press(forDuration: 0.15, thenDragTo: end)
|
||||
} else {
|
||||
// Cell not hit-testable: drag the stage-2 content
|
||||
// directly — still content, still never scrollBars.
|
||||
let scrollView = app.scrollViews["stage-2"]
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.55))
|
||||
.press(forDuration: 0.15, thenDragTo:
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.15)))
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
button = app.buttons[identifier]
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Panel appears after the manifest; refresh populates the printer
|
||||
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
|
||||
// The gallery cell's Print button sits at the window's bottom
|
||||
// edge where synthesized scroll-wheel events are inert on the
|
||||
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
||||
// thumb instead — a real scroll that re-renders the cell onscreen.
|
||||
var printPage = app.buttons["btnPrintPage-0"]
|
||||
let scrollDeadline = Date().addingTimeInterval(15)
|
||||
while !printPage.isHittable, Date() < scrollDeadline {
|
||||
let scroller = app.scrollBars.allElementsBoundByIndex
|
||||
.first { $0.frame.height > $0.frame.width }
|
||||
if let scroller {
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.1))
|
||||
.press(forDuration: 0.1, thenDragTo:
|
||||
scroller.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.6)))
|
||||
} else {
|
||||
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
printPage = app.buttons["btnPrintPage-0"]
|
||||
}
|
||||
if printPage.isHittable {
|
||||
printPage.click()
|
||||
} else {
|
||||
// LazyVGrid cells can report a stale a11y frame — click the
|
||||
// point directly; the lp argv assert below still verifies.
|
||||
// edge; scroll until it is genuinely hittable (#132). Never
|
||||
// coordinate-click a stale frame — that point can be the Dock.
|
||||
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
|
||||
guard printPage.isHittable else {
|
||||
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||
"\(app.debugDescription)\nAXTREE-END")
|
||||
printPage.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
|
||||
return
|
||||
}
|
||||
printPage.click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||
|
||||
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDetectInstruments"].click()
|
||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||
|
||||
// Keep Auto (port 1) and start the session.
|
||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
||||
app.buttons["btnStartRead"].click()
|
||||
|
||||
// Calibrate.
|
||||
let calibrate = element("btnCalibrate")
|
||||
if !calibrate.waitForExistence(timeout: 25) {
|
||||
let error = element("chartreadLastError").label
|
||||
let value = element("chartreadLastError").value as? String ?? "<nil>"
|
||||
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
|
||||
}
|
||||
app.buttons["btnCalibrate"].click()
|
||||
|
||||
// Trigger each strip until all are read → Done & Save appears.
|
||||
driveStripsUntilDone()
|
||||
XCTAssertTrue(element("btnDoneRead").exists)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
// Wait until Start is enabled before clicking. Existence-only
|
||||
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||
driveOnePass(startButton: "btnStartRead")
|
||||
|
||||
// Averaging panel appears with one pass snapshot.
|
||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||
@@ -168,15 +154,34 @@ final class Milestone4UITests: XCTestCase {
|
||||
|
||||
/// Runs the mock handheld chartread session to completion
|
||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||
///
|
||||
/// `isEnabled` can be true in AX while the SwiftUI action is still
|
||||
/// a no-op (runs 35251, 35443). Re-click until the session is
|
||||
/// actually running (`btnCancel` is shown whenever
|
||||
/// `isChartreadRunning`), then wait for Calibrate.
|
||||
private func driveOnePass(startButton: String) {
|
||||
let start = app.buttons[startButton]
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, !start.isEnabled {
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||
let enabledBy = Date().addingTimeInterval(10)
|
||||
while Date() < enabledBy, !start.isEnabled {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertTrue(start.isEnabled)
|
||||
start.click()
|
||||
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||
|
||||
let sessionBy = Date().addingTimeInterval(25)
|
||||
while Date() < sessionBy, !chartreadSessionRunning {
|
||||
if start.exists, start.isEnabled {
|
||||
start.click()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
}
|
||||
XCTAssertTrue(
|
||||
chartreadSessionRunning,
|
||||
"\(startButton) click never started chartread; "
|
||||
+ "notice=\(element("noticeText").value as? String ?? "") "
|
||||
+ "start.exists=\(start.exists)"
|
||||
)
|
||||
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
app.buttons["btnCalibrate"].click()
|
||||
driveStripsUntilDone()
|
||||
@@ -184,6 +189,14 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
}
|
||||
|
||||
/// `btnCancel` is in the tree for the whole chartread session;
|
||||
/// Calibrate/Trigger only appear after the first classified prompt.
|
||||
private var chartreadSessionRunning: Bool {
|
||||
element("btnCancel").exists
|
||||
|| element("btnCalibrate").exists
|
||||
|| element("btnTrigger").exists
|
||||
}
|
||||
|
||||
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||
/// appears — the button is re-polled each pass so a click that
|
||||
/// races a state transition isn't lost.
|
||||
|
||||
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
|
||||
/// rows with expanding spacers once gave the stage an unbounded ideal
|
||||
/// width, and window centering shifted the 270 pt sidebar into
|
||||
/// negative X (issue #163). AX-tree existence checks cannot see that,
|
||||
/// so assert real frame geometry.
|
||||
func testCalibrationViewDoesNotOverflowWindow() throws {
|
||||
let calButton = app.buttons["btnCalibratePrinter"]
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||
calButton.tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.exists)
|
||||
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
|
||||
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
|
||||
let ret = app.buttons["btnCalReturn"]
|
||||
XCTAssertTrue(ret.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(ret.isHittable)
|
||||
}
|
||||
|
||||
/// "Return to Profiling" is the single Stage 0 exit and carries the
|
||||
/// cancel-action shortcut, so Escape must dismiss the dashboard too
|
||||
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
|
||||
/// runner (m10 phase-08), so the Escape check falls back to the
|
||||
/// deterministic button tap.
|
||||
func testCalibrationReturnButtonAndEscapeDismiss() throws {
|
||||
let calButton = app.buttons["btnCalibratePrinter"]
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||
calButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
let returnButton = app.buttons["btnCalReturn"]
|
||||
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(returnButton.isHittable)
|
||||
returnButton.tap()
|
||||
|
||||
let stage1 = app.descendants(matching: .any)["stage-1"]
|
||||
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||
|
||||
// Re-enter and try Escape; fall back to the button where the
|
||||
// runtime does not deliver typeKey.
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||
calButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
if !stage1.waitForExistence(timeout: 4) {
|
||||
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||
returnButton.tap()
|
||||
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
|
||||
/// A failing calibration targen surfaces the error through the
|
||||
/// wizard notice and restores the original basename (issue #80).
|
||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
import XCTest
|
||||
|
||||
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
|
||||
@MainActor
|
||||
final class Milestone6GamutUITests: XCTestCase {
|
||||
|
||||
/// Metal on the test host — the app under test runs on the same
|
||||
/// machine, so this predicts whether the sheet mounts SceneKit.
|
||||
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Never leave the gamut sheet up for `terminate()` (#147).
|
||||
if app != nil, element("btnCloseGamut").exists {
|
||||
element("btnCloseGamut").click()
|
||||
}
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
return el
|
||||
}
|
||||
|
||||
/// Inverse of `waitFor` — polls until the element leaves the tree.
|
||||
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !element(id).exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
|
||||
}
|
||||
|
||||
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
|
||||
/// is not stuck behind a key sheet (#147). No-op when already closed.
|
||||
private func closeGamutSheet() {
|
||||
let close = element("btnCloseGamut")
|
||||
guard close.waitForExistence(timeout: 5) else { return }
|
||||
close.click()
|
||||
waitForGone("gamutView")
|
||||
}
|
||||
|
||||
/// Build and verify the mock profile, then open the native gamut viewer.
|
||||
/// The viewer should load both the reference sRGB mesh and the profile
|
||||
/// gamut copied from that reference.
|
||||
func testViewGamutOpensSceneKitSheet() throws {
|
||||
private func openGamutSheet() {
|
||||
app.launch()
|
||||
if !app.wait(for: .runningForeground, timeout: 10) {
|
||||
app.activate()
|
||||
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
|
||||
waitFor("btnViewGamut").click()
|
||||
|
||||
_ = waitFor("gamutView")
|
||||
}
|
||||
|
||||
func testViewGamutOpensSceneKitSheet() throws {
|
||||
openGamutSheet()
|
||||
|
||||
let gamutView = waitFor("gamutView")
|
||||
XCTAssertTrue(gamutView.exists)
|
||||
|
||||
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
|
||||
let value = status.value as? String ?? ""
|
||||
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
|
||||
|
||||
// The fallback banner appears exactly when the host lacks Metal
|
||||
// — no SCNView is constructed without a GPU (#147).
|
||||
if hasGPU {
|
||||
XCTAssertFalse(
|
||||
element("gamutViewerUnavailable").exists,
|
||||
"GPU host must mount the SceneKit view, not the fallback")
|
||||
} else {
|
||||
_ = waitFor("gamutViewerUnavailable")
|
||||
}
|
||||
|
||||
// The reset button demonstrates that the viewer is interactive.
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
XCTAssertTrue(reset.isEnabled)
|
||||
|
||||
closeGamutSheet()
|
||||
}
|
||||
|
||||
/// Clicking Reset drives the live `SCNView` — runs only on Metal
|
||||
/// hosts, skipped on GPU-less runners so the same suite exercises
|
||||
/// 3D once CI has a GPU (#147).
|
||||
func testResetCameraInteractsWithScene() throws {
|
||||
guard hasGPU else { throw XCTSkip("No Metal") }
|
||||
openGamutSheet()
|
||||
|
||||
let reset = waitFor("btnResetGamutCamera")
|
||||
reset.click()
|
||||
|
||||
closeGamutSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Settings sheet UI tests (issue #165).
|
||||
///
|
||||
/// The Verification thresholds once shared one non-wrapping `HStack` and
|
||||
/// drew past the sheet's right clip on the macOS grouped `Form`. AX
|
||||
/// existence cannot see clipping (#163), so containment is asserted on
|
||||
/// real frame geometry against the sheet's bounds.
|
||||
///
|
||||
/// All three numeric fields also passed their default value as the
|
||||
/// `TextField` label; inside an `HStack` row that label renders inline —
|
||||
/// it is not a placeholder — producing "Stale after 30 [30] days". The
|
||||
/// fields are now direct `Form` children, so the descriptive label
|
||||
/// renders once in the label column and the box fills the control
|
||||
/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it.
|
||||
@MainActor
|
||||
final class SettingsUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
}
|
||||
|
||||
/// Sheet content lives under `app.sheets`, outside the main window's
|
||||
/// a11y tree (Milestone2 pattern).
|
||||
private var sheet: XCUIElement {
|
||||
app.sheets.firstMatch
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
let gear = app.buttons["openSettingsBtn"]
|
||||
XCTAssertTrue(gear.waitForExistence(timeout: 10))
|
||||
gear.click()
|
||||
XCTAssertTrue(sheet.waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
private func thresholdField(_ fieldID: String) -> XCUIElement {
|
||||
let field = sheet.textFields[fieldID]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)")
|
||||
return field
|
||||
}
|
||||
|
||||
private func replaceFieldValue(_ field: XCUIElement, with text: String) {
|
||||
field.click()
|
||||
app.typeKey("a", modifierFlags: .command)
|
||||
field.typeText(text)
|
||||
}
|
||||
|
||||
private func waitForSheetDismiss(timeout: TimeInterval = 10) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !sheet.exists { return }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertFalse(sheet.exists, "Expected sheet to disappear")
|
||||
}
|
||||
|
||||
/// Both ΔE rows must render fully inside the 560×620 sheet: the
|
||||
/// label-column `StaticText`s and the control-column fields all sit
|
||||
/// within the sheet bounds, the two fields share the Form's control
|
||||
/// column margin, and the Warning row sits below the Good row so the
|
||||
/// two cannot overlap on one clipped line.
|
||||
///
|
||||
/// The numeric fields are direct `Form` children, so macOS lifts
|
||||
/// each `TextField` label into the right-aligned label column and
|
||||
/// the editable box fills the control column — the same layout the
|
||||
/// Pickers use. The control column ends only ~3.5 pt inside the
|
||||
/// sheet (PopUpButtons reach it too), so the right-edge assertion is
|
||||
/// "inside the sheet", not the older 12 pt compact-field inset.
|
||||
func testVerificationRowsStayInsideSheet() throws {
|
||||
openSettings()
|
||||
|
||||
let goodField = thresholdField("settingsDeltaEGood")
|
||||
let warningField = thresholdField("settingsDeltaEWarning")
|
||||
|
||||
// Labels render as sibling staticTexts in the label column.
|
||||
let goodLabel = sheet.staticTexts["Good ΔE ≤"]
|
||||
let warningLabel = sheet.staticTexts["Warning ΔE ≤"]
|
||||
XCTAssertTrue(goodLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(warningLabel.waitForExistence(timeout: 5))
|
||||
|
||||
// Left boundary: labels must be inside the sheet with >=12 pt inset
|
||||
XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||
XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0)
|
||||
|
||||
// Right boundary: fields must not draw past the sheet's clip
|
||||
XCTAssertTrue(
|
||||
goodField.frame.maxX <= sheet.frame.maxX,
|
||||
"Good ΔE field clips the sheet's right edge")
|
||||
XCTAssertTrue(
|
||||
warningField.frame.maxX <= sheet.frame.maxX,
|
||||
"Warning ΔE field clips the sheet's right edge")
|
||||
|
||||
// Vertical separation
|
||||
XCTAssertTrue(
|
||||
warningField.frame.minY > goodField.frame.minY,
|
||||
"thresholds must be two separate rows")
|
||||
|
||||
// Both threshold fields align at the same control column margin
|
||||
XCTAssertTrue(
|
||||
abs(goodField.frame.minX - warningField.frame.minX) <= 1.0,
|
||||
"Good and Warning ΔE fields should align at the same column margin")
|
||||
|
||||
// Other labels must not overflow the left boundary
|
||||
let defaultInstLabel = sheet.staticTexts["Default instrument"]
|
||||
XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(
|
||||
defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||
"Default instrument label must not overflow left edge")
|
||||
|
||||
let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"]
|
||||
XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(
|
||||
bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0,
|
||||
"Bundled sidecars label must not overflow left edge")
|
||||
|
||||
sheet.buttons["Cancel"].click()
|
||||
waitForSheetDismiss(timeout: 5)
|
||||
}
|
||||
|
||||
/// `warning <= good` fails `AppSettings.validate()` and keeps the
|
||||
/// sheet open with the contract error text; restoring valid values
|
||||
/// lets Save dismiss (issue #165 acceptance, strings are the #5
|
||||
/// contract).
|
||||
func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws {
|
||||
openSettings()
|
||||
|
||||
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1")
|
||||
sheet.buttons["Save"].click()
|
||||
|
||||
let error = sheet.staticTexts[
|
||||
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||
]
|
||||
XCTAssertTrue(error.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet")
|
||||
|
||||
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5")
|
||||
sheet.buttons["Save"].click()
|
||||
waitForSheetDismiss(timeout: 10)
|
||||
}
|
||||
|
||||
/// macOS renders a `TextField`'s first argument as a label, not a
|
||||
/// placeholder — inside the old `HStack` rows it drew inline, so the
|
||||
/// sheet read "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
|
||||
/// As direct `Form` children each label now renders exactly once, in
|
||||
/// the label column; no `staticText` may echo the field's value.
|
||||
func testNumericFieldsCarryLabelsNotDuplicatedValues() throws {
|
||||
openSettings()
|
||||
|
||||
// (identifier, label-column text, rendered default value, the
|
||||
// literal that used to double-render as the field's label)
|
||||
// `value:` shows the formatted number — 2.0 renders as "2".
|
||||
let rows: [(id: String, label: String, value: String, dup: String)] = [
|
||||
("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"),
|
||||
("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"),
|
||||
("settingsCalStaleDays", "Stale after (days)", "30", "30"),
|
||||
]
|
||||
|
||||
for spec in rows {
|
||||
let field = sheet.textFields[spec.id]
|
||||
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)")
|
||||
XCTAssertEqual(
|
||||
field.value as? String, spec.value,
|
||||
"\(spec.id) default value changed unexpectedly")
|
||||
XCTAssertTrue(
|
||||
sheet.staticTexts[spec.label].waitForExistence(timeout: 5),
|
||||
"\(spec.id) must render \"\(spec.label)\" once in the label column")
|
||||
for ghost in Set([spec.value, spec.dup]) {
|
||||
XCTAssertFalse(
|
||||
sheet.staticTexts[ghost].exists,
|
||||
"\(spec.id) must not render \"\(ghost)\" as a second label")
|
||||
}
|
||||
XCTAssertTrue(
|
||||
field.frame.maxX <= sheet.frame.maxX,
|
||||
"\(spec.id) field clips the sheet's right edge")
|
||||
}
|
||||
|
||||
sheet.buttons["Cancel"].click()
|
||||
waitForSheetDismiss(timeout: 5)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
|
||||
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
|
||||
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
|
||||
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
|
||||
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
|
||||
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
||||
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
|
||||
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||
|
||||
|
||||
+4
-1
@@ -43,6 +43,8 @@ Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
|
||||
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
|
||||
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
|
||||
| `APPLE_TEAM_ID` | Team ID for `notarytool` |
|
||||
| `RELEASE_TAG` | Optional. Simulates a tag build (`vX.Y.Z[-suffix]`); stamped into the bundle and DMG name |
|
||||
| `BUILD_NUMBER` | Optional `CFBundleVersion` override; default `git rev-list --count HEAD` |
|
||||
|
||||
Invocation for a signed, notarized DMG:
|
||||
|
||||
@@ -56,7 +58,8 @@ export APPLE_TEAM_ID="TEAMID"
|
||||
scripts/package-release.sh
|
||||
```
|
||||
|
||||
The script will emit `ICCery-<version>-<build>.dmg`. After mounting, verify Gatekeeper acceptance:
|
||||
The script will emit `ICCery-<version>-<build>.dmg` (or `ICCery-<tag>-<build>.dmg`
|
||||
when `RELEASE_TAG`/a `v*` git tag applies). After mounting, verify Gatekeeper acceptance:
|
||||
|
||||
```
|
||||
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
|
||||
|
||||
+18
@@ -21,6 +21,8 @@ targets:
|
||||
- ICCery.entitlements
|
||||
- ICCery.Debug.entitlements
|
||||
- Argyll
|
||||
- dmg-background.png
|
||||
- dmg-background@2x.png
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
@@ -40,6 +42,15 @@ targets:
|
||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||
fi
|
||||
basedOnDependencyAnalysis: false
|
||||
- name: Stamp release tag
|
||||
# GENERATE_INFOPLIST_FILE writes Info.plist after script phases and
|
||||
# only honours Apple's INFOPLIST_KEY_* allowlist — so the tag ships as
|
||||
# a bundled resource read by ArtefactFiles.appInfo (#189).
|
||||
script: |
|
||||
DEST="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
|
||||
mkdir -p "$DEST"
|
||||
printf '%s' "${ICCERY_RELEASE_TAG:-}" > "$DEST/ICCeryReleaseTag"
|
||||
basedOnDependencyAnalysis: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
@@ -52,6 +63,13 @@ targets:
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||
# `git describe` string); About shows it next to the marketing version.
|
||||
# Written to the bundled ICCeryReleaseTag resource by the
|
||||
# "Stamp release tag" phase — GENERATE_INFOPLIST_FILE drops custom
|
||||
# INFOPLIST_KEY_* names and generates the plist after script phases.
|
||||
# Tag builds hard-fail when the tag's X.Y.Z != MARKETING_VERSION above.
|
||||
ICCERY_RELEASE_TAG: ""
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
|
||||
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$TAG" in
|
||||
*prerelease*) PRERELEASE=true ;;
|
||||
*) PRERELEASE=false ;;
|
||||
esac
|
||||
|
||||
DMG="${1:-}"
|
||||
if [ -z "$DMG" ]; then
|
||||
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
|
||||
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
"$API/repos/$REPO/releases/tags/$TAG" || true)"
|
||||
|
||||
if [ "$STATUS" = "404" ]; then
|
||||
echo "==> Creating release $TAG"
|
||||
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
|
||||
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$API/repos/$REPO/releases" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
|
||||
fi
|
||||
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
|
||||
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# scripts/dmgbuild-settings.py
|
||||
#
|
||||
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
|
||||
# in the environment, or accept the defaults. Background art can be supplied
|
||||
# later by placing a PNG at Resources/dmg-background.png and setting
|
||||
# DMG_BACKGROUND.
|
||||
# dmgbuild settings for ICCery. scripts/package-release.sh exports
|
||||
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
|
||||
# HiDPI TIFF). A missing background is a hard error — a grey
|
||||
# Finder window is not an acceptable release artefact (#95).
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
|
||||
|
||||
files = [app_path]
|
||||
|
||||
# Background art is optional. If the referenced PNG does not exist, fall back
|
||||
# to a plain window. See docs/23-assets.md for the DMG background spec.
|
||||
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
|
||||
if background and not os.path.exists(background):
|
||||
background = None
|
||||
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
|
||||
# about classic Alias Manager blobs. package-release.sh always passes
|
||||
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
|
||||
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
|
||||
# have; 1.6.5 + TIFF is what CI can ship (#95).
|
||||
background = os.environ.get('DMG_BACKGROUND', '')
|
||||
if not background or not os.path.isfile(background):
|
||||
sys.stderr.write(
|
||||
'error: DMG_BACKGROUND must point at an existing image '
|
||||
'(got %r)\n' % background)
|
||||
sys.exit(1)
|
||||
|
||||
icon = None
|
||||
|
||||
# Window size is enough for the app icon and the Applications alias.
|
||||
# Bitmap is slightly larger than this rect so title-bar chrome on
|
||||
# 14+ does not crop the wordmark.
|
||||
window_rect = ((100, 100), (660, 400))
|
||||
|
||||
# Use icon view without extra chrome.
|
||||
|
||||
@@ -2,19 +2,79 @@
|
||||
# scripts/ensure-host-tools.sh
|
||||
#
|
||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
||||
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
|
||||
# current formula requires Xcode 15.3, which cannot be installed on
|
||||
# macOS 12).
|
||||
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
|
||||
# build/.venv-dmgbuild so the test job never pip-installs it.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone.
|
||||
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
|
||||
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
|
||||
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
|
||||
# offer 1.6.5 and keep a cached venv on that version (#95).
|
||||
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone unless the
|
||||
# dmgbuild pin is not met.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
XCODEGEN_VERSION="2.38.0"
|
||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
DMGBUILD_PIN="1.6.7"
|
||||
|
||||
echo "==> Ensuring dmgbuild"
|
||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dmgbuild) INSTALL_DMGBUILD=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$INSTALL_DMGBUILD" = "1" ]; then
|
||||
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
|
||||
mkdir -p "$ROOT/build"
|
||||
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
|
||||
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
|
||||
# the next pip invocation crashed. Recreate if pip is already dead.
|
||||
if [ -x "$VENV/bin/python" ] \
|
||||
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||
echo "==> venv pip is broken; recreating $VENV"
|
||||
rm -rf "$VENV"
|
||||
fi
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
|
||||
PIP_IGNORE_REQUIRES_PYTHON=1
|
||||
export PIP_IGNORE_REQUIRES_PYTHON
|
||||
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
|
||||
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
|
||||
"dmgbuild==$DMGBUILD_PIN" \
|
||||
'ds_store>=1.3.3' \
|
||||
'mac_alias>=2.2.3'
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version
|
||||
print("dmgbuild", version("dmgbuild"))
|
||||
print("ds_store", version("ds_store"))
|
||||
print("mac_alias", version("mac_alias"))
|
||||
parts=[]
|
||||
for p in version("dmgbuild").split("."):
|
||||
try:
|
||||
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
parts += [0, 0, 0]
|
||||
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
|
||||
'
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$VENV/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
else
|
||||
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
|
||||
fi
|
||||
|
||||
if command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||
|
||||
@@ -106,9 +106,19 @@ fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
|
||||
VENDOR_ROOT="$ROOT/Vendor/Argyll"
|
||||
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
|
||||
if [ -f "$license_src" ]; then
|
||||
cp "$license_src" "$VENDOR_ROOT/License.txt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
|
||||
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||
|
||||
@@ -45,6 +45,13 @@ DEST="platform=macOS"
|
||||
IDENTITY="${CODESIGN_IDENTITY:--}"
|
||||
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
|
||||
|
||||
# Resolve the version triple from the release tag / git state. On tag builds
|
||||
# (RELEASE_TAG=v*) this hard-fails if the tag's X.Y.Z != project.yml
|
||||
# MARKETING_VERSION, so a mis-tag cannot ship mismatched metadata (#189).
|
||||
echo "==> Resolving version"
|
||||
eval "$(scripts/version.sh)"
|
||||
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
|
||||
|
||||
echo "==> Building universal Release app"
|
||||
BUILD_EXTRA=""
|
||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||
@@ -60,6 +67,9 @@ xcodebuild \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY="$IDENTITY" \
|
||||
MARKETING_VERSION="$MARKETING_VERSION" \
|
||||
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
|
||||
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG" \
|
||||
$BUILD_EXTRA \
|
||||
build
|
||||
|
||||
@@ -103,31 +113,59 @@ EOF
|
||||
scripts/verify-sidecar-signatures.sh "$APP"
|
||||
fi
|
||||
|
||||
echo "==> Installing / locating dmgbuild"
|
||||
echo "==> Locating dmgbuild"
|
||||
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
|
||||
# which created build/.venv-dmgbuild and prepended it to PATH. Local
|
||||
# runs bootstrap the same venv if dmgbuild is missing.
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if [ ! -d "$VENV/bin" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install --upgrade pip
|
||||
"$VENV/bin/pip" install dmgbuild
|
||||
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
fi
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
|
||||
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "dmgbuild $(command -v dmgbuild)"
|
||||
if [ -x "$VENV/bin/python" ]; then
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
|
||||
fi
|
||||
|
||||
PNG1X="$ROOT/Resources/dmg-background.png"
|
||||
PNG2X="$ROOT/Resources/dmg-background@2x.png"
|
||||
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
|
||||
echo "error: missing $PNG1X or $PNG2X" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$ROOT/build"
|
||||
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
|
||||
echo "==> Building HiDPI DMG background TIFF"
|
||||
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
|
||||
if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building DMG"
|
||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
|
||||
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
||||
DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg"
|
||||
# Tagged/described builds carry the tag in the DMG name so prerelease
|
||||
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain
|
||||
# release tag keeps the classic ICCery-<ver>-<build>.dmg form.
|
||||
TAG_NAME="${ICCERY_RELEASE_TAG#v}"
|
||||
case "$TAG_NAME" in
|
||||
''|"$VERSION"|dev) DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg" ;;
|
||||
*) DMG="ICCery-${TAG_NAME}-${BUILD_NUM}.dmg" ;;
|
||||
esac
|
||||
VOLUME_NAME="ICCery ${VERSION}"
|
||||
|
||||
DMG_APP="$APP" \
|
||||
DMG_FILENAME="$DMG" \
|
||||
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
||||
DMG_BACKGROUND="$DMG_BACKGROUND" \
|
||||
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
||||
|
||||
echo "DMG: $PWD/$DMG"
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/sh
|
||||
# scripts/version.sh
|
||||
#
|
||||
# Resolve the ICCery version triple and print KEY=value lines for eval:
|
||||
#
|
||||
# ICCERY_RELEASE_TAG release tag / describe string stamped into the
|
||||
# bundle's ICCeryReleaseTag Info.plist key and shown
|
||||
# in the About dialog.
|
||||
# MARKETING_VERSION CFBundleShortVersionString — strict X.Y.Z only
|
||||
# (Apple forbids suffixes; tag payload never lands here).
|
||||
# CURRENT_PROJECT_VERSION CFBundleVersion — monotonically increasing integer.
|
||||
# macOS/App-Store convention: never reset per version.
|
||||
#
|
||||
# Resolution:
|
||||
# RELEASE_TAG env matching 'v[0-9]*' wins (CI tag builds; branch pushes pass
|
||||
# the branch name and are ignored). Otherwise `git describe` on the worktree.
|
||||
# Marketing version = first three numeric components of the tag core; a tag
|
||||
# with no numeric core falls back to project.yml's MARKETING_VERSION.
|
||||
# Build number = BUILD_NUMBER env override, else `git rev-list --count HEAD`.
|
||||
#
|
||||
# Hard fails (release-tag builds only): tag core not 1-3 numeric components,
|
||||
# or parsed X.Y.Z != project.yml MARKETING_VERSION — bump project.yml or fix
|
||||
# the tag before packaging.
|
||||
#
|
||||
# Usage: eval "$(scripts/version.sh)"
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
YML_MARKETING="$(sed -n 's/^ *MARKETING_VERSION: *"\(.*\)"/\1/p' project.yml | head -n 1)"
|
||||
|
||||
TAG="${RELEASE_TAG:-}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) ;; # real release tag
|
||||
*) TAG="" ;; # branch name (develop, feat/...) or empty
|
||||
esac
|
||||
IS_RELEASE_TAG=0
|
||||
[ -n "$TAG" ] && IS_RELEASE_TAG=1
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(git describe --tags --always --dirty --match 'v[0-9]*' 2>/dev/null || true)"
|
||||
fi
|
||||
[ -n "$TAG" ] || TAG="dev"
|
||||
|
||||
# Marketing version: strip leading v, cut at first '-', keep first 3 dot
|
||||
# components. "v2.0.0-pre2" -> "2.0.0"; "v2.0.0.0-x" -> "2.0.0";
|
||||
# "v2.0.0-5-gsha" -> "2.0.0"; bare sha/"dev" -> no numeric core -> fallback.
|
||||
CORE="${TAG#v}"
|
||||
CORE="${CORE%%-*}"
|
||||
MV=""
|
||||
case "$CORE" in
|
||||
*[!0-9.]*|'') ;; # non-numeric core — not a version tag
|
||||
*)
|
||||
MV="$(printf '%s' "$CORE" | cut -d. -f1-3)"
|
||||
# Every kept component must be non-empty digits.
|
||||
case "$MV" in
|
||||
*[!0-9.]*|''|*..*|.*|*.) MV="" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$IS_RELEASE_TAG" -eq 1 ]; then
|
||||
if [ -z "$MV" ]; then
|
||||
echo "error: release tag '$TAG' has no X.Y.Z numeric core" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$YML_MARKETING" ] && [ "$MV" != "$YML_MARKETING" ]; then
|
||||
echo "error: tag '$TAG' resolves to $MV but project.yml MARKETING_VERSION=$YML_MARKETING; bump project.yml or fix the tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
[ -n "$MV" ] || MV="$YML_MARKETING"
|
||||
[ -n "$MV" ] || MV="0.0.0"
|
||||
|
||||
BUILD="${BUILD_NUMBER:-}"
|
||||
if [ -z "$BUILD" ]; then
|
||||
BUILD="$(git rev-list --count HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
case "$BUILD" in
|
||||
''|*[!0-9]*) BUILD="1" ;;
|
||||
esac
|
||||
[ "$BUILD" -gt 0 ] 2>/dev/null || BUILD="1"
|
||||
|
||||
printf 'ICCERY_RELEASE_TAG=%s\n' "$TAG"
|
||||
printf 'MARKETING_VERSION=%s\n' "$MV"
|
||||
printf 'CURRENT_PROJECT_VERSION=%s\n' "$BUILD"
|
||||
Reference in New Issue
Block a user