Compare commits

..
Author SHA1 Message Date
gronod 1d056c26bf docs: use PlantUML maps so Kroki fits a phone width
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Class diagrams rendered 6556px wide; Gitea clipped that to a
black arrowhead and the UML C icon. Stacked maps are 560-780px
and render as labelled tables on public Kroki.
2026-09-14 15:04:22 +00:00
gronod c4f037535a docs: restore PlantUML UI maps; draw groups as rectangles
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Revert the maps to 368ae56 and fix the Kroki render: default
package style is a folder, which Gitea scaled to a black tab.
Groups are rectangles on a white canvas; shape stereotypes
(<<Button>>, <<*.swift>>) are folded into the title so C4/sprites
cannot retarget the box.
2026-09-14 14:56:52 +00:00
gronod 191bcb0280 docs: replace hairball graphs with small overviews and tables
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Gitea scaled the 50-node GraphViz maps to unreadably tiny black
boxes. Overviews are now ~12 nodes on an opaque white canvas;
enable/hide rules live in markdown tables that work in dark mode.
2026-09-14 14:44:59 +00:00
gronod 9c625ee21f docs: dark-mode GraphViz palette for Gitea Kroki
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Unfilled nodes and default black type sat on a transparent SVG,
so dark mode showed only tiny black boxes. Opaque GitHub-dark
fills, light labels, larger type.
2026-09-14 14:40:28 +00:00
gronod 40cc4ad701 docs: drop PlantUML UI maps that Kroki 0.30.1 cannot run without AVX2
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
2026-09-14 14:25:52 +00:00
gronod 658d9813bd docs: render UI maps with GraphViz for Kroki 0.30.1
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 12m40s
Kroki 0.30.1 PlantUML is a GraalVM native image and requires AVX2.
This host has none, so the maps now use GraphViz (dot) — one
diagram per file, plus a markdown page with graphviz fences.
2026-09-14 14:25:35 +00:00
gronod 368ae56cf3 docs: split UI PlantUML map into one diagram per file
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 40m37s
macOS CI / package (pull_request) Skipped
Kroki only renders the first @startuml block in a file.
2026-09-14 14:18:07 +00:00
gronod bd539df009 docs: PlantUML map of every interactive control
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 3m58s
macOS CI / package (pull_request) Skipped
Identifiers, source files, and enable/hide/disable rules for the
wizard, sidebar, sheets, and File menu.
2026-09-14 14:03:26 +00:00
gronod 1b91b0a94e Merge pull request 'fix(ui): media Apply missing-printer notice visible in manage sheet (#170)' (#171) from fix/170-media-library-missing-printer-notice into develop
macOS CI / build-and-test (push) Failing after 42m49s
macOS CI / package (push) Skipped
fix(ui): media Apply missing-printer notice visible in manage sheet (#170)

Fixes #170
2026-09-14 14:37:05 +01:00
gronod 890e7281eb fix(ui): show media Apply failure inside the manage sheet (#170)
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 2m31s
Failed Apply kept the sheet open while the window banner sat
behind it, so Monterey XCTest never saw noticeText. Mirror the
failure string in the dialog (manageMediaNotice) and point the
UI test at that identifier.

Fixes #170
2026-09-14 13:36:59 +00:00
gronod ccaad2d940 docs: sync README with Monterey floor, M10, wiki, and packaging
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 51s
macOS CI / build-and-test (push) Failing after 43m55s
macOS CI / package (push) Skipped
Requirements were still Xcode 15.4 / Swift 6. Point the product
at the user wiki, record host-arch tests, the dmgbuild venv, and
the current develop-first git workflow.
2026-09-14 12:34:16 +00:00
gronod 7e5e20cb69 Merge origin/main into develop (keep README)
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16m10s
2026-09-14 13:23:21 +01:00
gronod e72039c123 Delete README.md 2026-09-14 13:11:43 +01:00
gronod fa02e774ca Merge pull request 'fix(packaging): DMG background visible on Sonoma+ (#95)' (#167) from fix/95-dmg-background-sonoma into develop
macOS CI / build-and-test (pull_request) Skipped
macOS CI / build-and-test (push) Successful in 38m21s
macOS CI / package (push) Successful in 4m14s
Reviewed-on: #167
2026-09-14 11:55:39 +01:00
gronod 2a8ad4d5d4 fix(ci): cap pip below 26.1 in the Python 3.9 dmgbuild venv
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Successful in 4m5s
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 3m29s
Unbounded pip upgrade installed 26.2.1, which uses
dataclass(slots=True) and crashes on Monterey 3.9. Recreate
the venv if pip is already broken, then pin pip>=24.3,<26.1
before force-installing dmgbuild 1.6.7.
2026-09-14 10:34:16 +00:00
gronod c2ac9341c8 fix(ci): force dmgbuild 1.6.7 into the package venv
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m6s
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Failing after 35s
Python 3.9 hides 1.6.6+ (Requires-Python >=3.10), so
dmgbuild>=1.6.5 left a cached 1.6.5 install. The wheels are
py3-none-any; install 1.6.7 with PIP_IGNORE_REQUIRES_PYTHON,
force-reinstall ds_store>=1.3.3 and mac_alias>=2.2.3, and fail
if the venv is still older than 1.6.7.
2026-09-14 10:29:33 +00:00
gronod 1c310706e4 fix(ci): isolate dmgbuild in a venv, skip it on the test job
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m50s
macOS CI / build-and-test (push) Successful in 13s
macOS CI / package (push) Successful in 4m12s
ensure-host-tools.sh always bootstraps xcodegen. dmgbuild now
lives in build/.venv-dmgbuild and is only installed when
INSTALL_DMGBUILD=1 (package job). The test job no longer
pip-installs dmgbuild, which is what failed on Python 3.9.

package-release.sh reuses that venv (or bootstraps it locally)
instead of creating a second copy.
2026-09-14 10:22:09 +00:00
gronod 7f6c47d85c fix(ci): do not require dmgbuild 1.6.7 on Monterey Python 3.9
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 6m31s
The tools step runs on the test job. System Python is 3.9;
dmgbuild 1.6.6+ declares Requires-Python >=3.10, so pip only
lists 1.6.5 and the 1.6.7 floor failed the gate.

Install the newest wheel this interpreter accepts. Keep the
HiDPI TIFF background path and the hard-fail if art is missing.
2026-09-14 10:14:29 +00:00
gronod 3b6323378e fix(packaging): DMG background visible on Sonoma+ (#95)
macOS CI / build-and-test (pull_request) Failing after 20s
macOS CI / package (pull_request) Skipped
Pin dmgbuild >= 1.6.7 so Finder gets a bookmark instead of a
Monterey-era Alias Manager blob. Feed a HiDPI TIFF from
tiffutil rather than the 1x RGBA PNG, and fail the package job
if the art is missing instead of shipping a grey window.

Exclude installer chrome from the app bundle; it is not an
in-app asset.
2026-09-14 09:57:37 +00:00
gronod ad7b91cf91 Merge pull request 'fix(ui): settings sheet — ΔE clip + duplicated numeric-field labels (#165)' (#166) from fix/165-settings-deltae-clip into develop
macOS CI / package (push) Successful in 4m48s
macOS CI / build-and-test (push) Successful in 43m0s
Reviewed-on: #166
2026-09-14 07:56:58 +01:00
gronod 8b931e3625 fix(ui): settings numeric fields no longer render default value as inline label (#165)
macOS CI / build-and-test (pull_request) Successful in 37m40s
macOS CI / package (pull_request) Skipped
- TextField("30"/"2.0"/"5.0") passed the default value as the label,
  which macOS draws inline next to the box — the rows read
  "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
- The three fields are now direct Form children carrying their
  descriptive label, so it renders once in the label column and the box
  fills the control column, matching the Pickers. The stale-days row
  folds "days" into the label ("Stale after (days)").
- New identifier settingsCalStaleDays; docs/21 roster 357→358.
- New testNumericFieldsCarryLabelsNotDuplicatedValues asserts each
  field's value, a single label-column staticText, and no staticText
  echoing the old label literal. testVerificationRowsStayInsideSheet
  updated for label-column geometry (fields end ~3.5 pt inside the
  sheet, same as the PopUpButtons — the 12 pt inset only applied to the
  old 60 pt boxes).

Refs #165
2026-09-14 02:05:06 +01:00
gronod 73b18dec5b test(settings): drop waitForNonExistence for Xcode 14.2 CI (#165)
macOS CI / build-and-test (pull_request) Failing after 4m12s
macOS CI / package (pull_request) Skipped
waitForNonExistence requires the macOS 14 SDK XCTest; the macos-12 runner toolchain has no such member on XCUIElement. Poll sheet.exists on the run loop instead, matching the waitForGone pattern in Milestone10GamutCompareUITests.
2026-09-14 01:08:01 +01:00
gronod 3bc0d14a34 test(settings): add Foundation import and simplify column alignment assertion
macOS CI / build-and-test (pull_request) Failing after 1m40s
macOS CI / package (pull_request) Skipped
2026-09-14 00:46:32 +01:00
gronod 4aa2815c6e test(settings): use abs diff instead of accuracy for Swift 5.7 compatibility
macOS CI / build-and-test (pull_request) Failing after 1m50s
macOS CI / package (pull_request) Skipped
2026-09-14 00:42:32 +01:00
gronod 8596f15d52 fix(ui): settings ΔE threshold rows no longer clip the sheet edge (#165)
macOS CI / build-and-test (pull_request) Failing after 2m55s
macOS CI / package (pull_request) Skipped
- Split Section("Verification") from one 4-across non-wrapping HStack into
  two adjacent label+field rows (#settingsDeltaEGood, #settingsDeltaEWarning).
- Add .padding(.leading, 45) to the Settings Form so the control column
  aligns at ~522 pt, matching develop, and all labels have 41–100 pt
  breathing room from the left boundary.
- Add SettingsUITests with frame-containment, control alignment, and
  validation/save round-trip coverage.
- Update docs/21-ui-reference.md Settings entry with new identifiers.

Refs #165
2026-09-14 00:34:35 +01:00
gronod 407136c5a2 fix(ui): calibrate dashboard no longer overflows window; Escape exits (#163)
macOS CI / build-and-test (pull_request) Successful in 6m4s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Canceled after 1m44s
macOS CI / package (push) Skipped
2026-09-13 22:16:01 +01:00
gronod 83b5f6bf75 Merge pull request 'ci(macos): skip tests on tags containing "prerelease"' (#162) from chore/ci-prerelease-skip into develop
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16s
Reviewed-on: #162
2026-09-13 21:04:38 +01:00
gronod 16df18127c ci(macos): skip tests on tags containing "prerelease"
macOS CI / build-and-test (pull_request) Successful in 37m58s
macOS CI / package (pull_request) Skipped
2026-09-13 20:41:32 +01:00
gronod 78baa49e61 Merge pull request 'fix(gamut): Close button; skip SCNView without Metal (#147)' (#161) from fix/147-gamut-close-no-gpu into milestone/m10-studio
macOS CI / build-and-test (pull_request) Successful in 32m11s
macOS CI / package (pull_request) Skipped
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 13m23s
2026-09-13 18:49:35 +01:00
gronod 04a563c54a test(print): scroll gallery until btnPrintPage is hittable (#132) 2026-09-13 18:24:43 +01:00
gronod 2cbd43470d fix(gamut): Close button; skip SCNView without Metal (#147)
Refs #147 #146
2026-09-13 18:24:43 +01:00
gronod 14f9e6f78d Merge pull request 'fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)' (#160) from fix/147-gamut-toggle-disabled into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 5m30s
2026-09-13 17:05:05 +01:00
gronod f16825be72 fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)
Refs #147 #146
2026-09-13 17:03:56 +01:00
gronod d37dcedfd9 Update README.md 2026-09-09 19:18:43 +01:00
29 changed files with 1321 additions and 254 deletions
+16 -4
View File
@@ -30,19 +30,26 @@ jobs:
fi fi
echo "$line" echo "$line"
# Tag pushes whose name contains "prerelease" skip the test build and both
# test legs: they exist to package a build already validated elsewhere.
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be # Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned # installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead. # prebuilt release instead. dmgbuild is not installed here — the
# test job does not package (#95).
- name: Ensure host tools - name: Ensure host tools
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: scripts/ensure-host-tools.sh run: scripts/ensure-host-tools.sh
- name: Generate Xcode project - name: Generate Xcode project
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
# Tests only ever run on the runner's own architecture; build # Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still # just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary. # produces the universal Release binary.
- name: Build for testing (host arch) - name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: | run: |
xcodebuild build-for-testing \ xcodebuild build-for-testing \
-scheme ICCery \ -scheme ICCery \
@@ -60,6 +67,7 @@ jobs:
# launch (run 31992) — so sign every embedded copy once the build is # launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119). # done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks - name: Sign package product frameworks
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: | run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \ find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do | while IFS= read -r -d '' fw; do
@@ -68,6 +76,7 @@ jobs:
done done
- name: Test unit (ICCeryCoreTests) - name: Test unit (ICCeryCoreTests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: | run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
@@ -90,6 +99,7 @@ jobs:
# tag packaging can proceed. Real XCTest assertion failures # tag packaging can proceed. Real XCTest assertion failures
# still fail the job. # still fail the job.
- name: Test UI (ICCeryUITests) - name: Test UI (ICCeryUITests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: | run: |
set -o pipefail set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)" XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
@@ -154,7 +164,7 @@ jobs:
# xcresult on failure — upload it so UI failures can be triaged # xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126). # without access to the runner (#126).
- name: Prepare Node CA bundle (failure path) - name: Prepare Node CA bundle (failure path)
if: failure() if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: | run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem" NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \ security find-certificate -a -p \
@@ -166,7 +176,7 @@ jobs:
fi fi
- name: Upload UI test xcresult - name: Upload UI test xcresult
if: failure() if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
env: env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
@@ -184,8 +194,10 @@ jobs:
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild; # scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109). # see build-and-test for why brew is not used on macOS 12 (#109).
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
# the test job never pip-installs it (#95).
- name: Ensure host tools - name: Ensure host tools
run: scripts/ensure-host-tools.sh run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
- name: Package release - name: Package release
run: scripts/package-release.sh run: scripts/package-release.sh
+143 -51
View File
@@ -1,45 +1,75 @@
# ICCery # ICCery
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine. Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from
chart generation through measurement, `colprof`, verification, and ColorSync
install. It is **not** a colour engine.
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll. **End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
covers every screen (Getting Started through Troubleshooting). This README is
for building, packaging, and contributing.
All measurement, chart generation, and profile mathematics live in the
[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned
as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| | | | | |
|---|---| |---|---|
| Product | ICCery v2 for macOS | | Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` | | Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner |
| Default branch | `develop` | | Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` | | M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` | | M7 | Pre-UAT hardening — shipped |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` | | M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) | | M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` | | M10 | Studio workflow: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does ## What it does
The wizard is artefact-gated: The wizard is artefact-gated:
1. **Stage 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore 1. **Stage 1** — `targen` → `.ti1`
2. **Stage 1** — `targen` → `.ti1` 2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
3. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel` 3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
4. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000 4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
5. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile 5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
Plus CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging. Plus:
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox. - **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename
- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv`
- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal`
- **Spot Read** — live one-patch Lab/XYZ from the instrument
- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE
- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect
- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging
- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma)
**Not this product:** display calibration (`dispwin` / `dispread`), i18n,
Windows/Linux print trees, in-process Argyll, App Sandbox.
## Requirements ## Requirements
- macOS 14+ To **run** a packaged build:
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
- Network once, to fetch Argyll sidecars
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`. - macOS 12.0 Monterey or later (Intel or Apple silicon)
To **build** on the supported CI/host floor:
- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7)
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current
formula needs Xcode 15.3; CI installs the pinned zip via
`scripts/ensure-host-tools.sh`)
- Network once, to fetch Argyll sidecars
- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild`
(`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`)
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in
`ICCery.entitlements`.
## Build ## Build
@@ -49,7 +79,7 @@ cd iccery-v2-mac
git checkout develop git checkout develop
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
make test # xcodegen + xcodebuild build test make test # xcodegen + xcodebuild build test (host arch)
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
``` ```
@@ -62,9 +92,14 @@ xcodebuild test -scheme ICCery \
ARCHS="$(uname -m)" 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 ```bash
# optional # optional
@@ -74,9 +109,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
export GITEA_TOKEN=… # private releases export GITEA_TOKEN=… # private releases
``` ```
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`. `make clean` drops `ICCery.xcodeproj`, `DerivedData`, and
`Packages/ICCeryCore/.build`.
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate. Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
and regenerate.
## Release packaging ## Release packaging
@@ -84,11 +121,20 @@ Do not open the generated xcodeproj as the source of truth. Edit `project.yml` a
scripts/package-release.sh # fetch → sign → universal build → verify → DMG scripts/package-release.sh # fetch → sign → universal build → verify → DMG
``` ```
The script builds with a fixed derived data path (`build/DerivedData`), The script builds with a fixed derived data path (`build/DerivedData`), locates
locates `Release/ICCery.app` from it, signs the bundle, recursively verifies `Release/ICCery.app` from it, signs the bundle, recursively verifies every
every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed. writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
Sidecars stay ad-hoc signed inside the bundle — the app is never
`codesign --deep`ed.
`dmgbuild` is **not** a test-job dependency. The package job sets
`INSTALL_DMGBUILD=1` so `scripts/ensure-host-tools.sh` creates
`build/.venv-dmgbuild`. On the Monterey runner (Python 3.9) that install uses
`PIP_IGNORE_REQUIRES_PYTHON=1` and pins `pip>=24.3,<26.1` (pip 26.1+ needs
3.10). Missing background art is a hard fail (#95).
Environment variables read by the pipeline: Environment variables read by the pipeline:
@@ -110,30 +156,47 @@ Resources/ assets; Argyll reference files (not the tools)
Vendor/Argyll/ fetched sidecars (gitignored) Vendor/Argyll/ fetched sidecars (gitignored)
Tests/ICCeryCoreTests/ argv goldens, parsers, stores Tests/ICCeryCoreTests/ argv goldens, parsers, stores
Tests/ICCeryUITests/ fixture / mock-binary UI tests Tests/ICCeryUITests/ fixture / mock-binary UI tests
scripts/ensure-host-tools.sh
scripts/fetch-argyll.sh scripts/fetch-argyll.sh
scripts/package-release.sh
docs/ functional spec + v2 ticket plan docs/ functional spec + v2 ticket plan
``` ```
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is not in this tree. `ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
not in this tree.
## Architecture ## Architecture
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set. - **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`, etc.) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate. bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags. is always set.
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit. - **`ProcessManager` actor** owns child lifetime. Streaming tools
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state. (`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`. (`printcal`, `applycal`, CUPS) use `runCaptured`. Exclusive `ProcessID`
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
must not replace the input profile on cancel or non-zero exit.
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
`@Observable`.
- **Print.** Unmanaged `lp` with ColorSync suppression
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
options win over derived CUPS keys. Never `lp -o raw`.
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
limit. Split large `VStack`/`Group` trees (#146).
## Tests ## Tests
```bash ```bash
# full suite (host arch) # full suite (host arch) — same as CI
xcodebuild test -scheme ICCery \ xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \ -destination 'platform=macOS' \
ARCHS="$(uname -m)" 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 # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
# examples # examples
@@ -143,9 +206,20 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
-only-testing:ICCeryUITests/Milestone5UITests -only-testing:ICCeryUITests/Milestone5UITests
``` ```
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted. CI (`.gitea/workflows/macos.yml`) 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**.
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile. UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
CUPS fixtures live under the test bundles; they must not be treated as proof
that a real `.gam` / `.icc` was extracted.
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are
manual and block release, not compile.
`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is
dropped on a fast mock exit; that is a `ProcessManager` drain race, not a
missing fixture.
## Instruments ## Instruments
@@ -156,11 +230,18 @@ Detected via bundled `instlist`:
- SpyderPrint (`p3`) - SpyderPrint (`p3`)
- SpectroScan (`SS`) - SpectroScan (`SS`)
- DTP20 / 22 / 41 / 51 - DTP20 / 22 / 41 / 51
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i` - XY tables (SpectroScan, i1iO) when the `instlist` name matches
`/spectro\s?scan|i1io/i`
## Docs ## Docs
Normative spec is [`docs/`](docs/README.md). Implementation order: | Where | Audience |
|---|---|
| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting |
| [`docs/`](docs/README.md) | Functional spec (normative for implementers) |
| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules |
Implementation order in `docs/`:
| Doc | Topic | | Doc | Topic |
|---|---| |---|---|
@@ -168,29 +249,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill | | [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv | | [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating | | [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
| [`docs/23-assets.md`](docs/23-assets.md) | Icons, DMG chrome |
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return | | [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets | | [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit | | [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
## Git ## Git
``` ```
develop develop # integration; PRs land here unless a milestone branch is announced
└── milestone/m10-studio # M10 integration branch main # protected release line (PR from develop)
└── feat/<issue>-<slug> # one issue per branch feat/<issue>-<slug>
fix/<issue>-<slug>
``` ```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #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; `milestone/m10-studio`
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
tickets.
`main` is push-protected and requires status check
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
block PR merges that touch matching paths — do not set that field to `*`.
## Licence ## Licence
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise. GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing
and personal evaluation only unless a separate grant says otherwise.
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break. ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay
subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning
via `$PATH`, is a licence break.
## Related ## Related
- [User wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`) - [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree) - [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
+6
View File
@@ -97,6 +97,12 @@ enum UITestHooks {
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") } static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
/// Relocate-folder result when a project's `cwd` is missing (#149). /// Relocate-folder result when a project's `cwd` is missing (#149).
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") } static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
/// host (#147). Set per-test only — never in a default launch env,
/// or CI's future GPU run would skip SceneKit too.
static var skipSceneKit: Bool {
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
}
// MARK: - Print panel / CUPS stubs (issue 13/17) // MARK: - Print panel / CUPS stubs (issue 13/17)
+69 -36
View File
@@ -7,48 +7,90 @@ struct CalibrationView: View {
@ObservedObject var wizard: WizardViewModel @ObservedObject var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Calibrate Printer") Text("Calibrate Printer")
.font(.title2.bold()) .font(.title2.bold())
.padding(.horizontal, 16) .foregroundStyle(Theme.text)
.padding(.top, 16) wedgeSection
workflowSection
if !model.calibrationLog.isEmpty { logSection }
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.background)
Form { Divider().overlay(Theme.border)
Section("Wedge Settings") {
HStack {
Spacer()
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCalReturn")
}
.padding(16)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("stage-cal")
}
// MARK: - Wedge settings
private var wedgeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
Picker("Colour Space", selection: $model.colourSpace) { Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb) Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk) Text("CMYK").tag(ColourSpace.cmyk)
} }
.pickerStyle(.segmented)
.frame(maxWidth: 220)
HStack { HStack(spacing: 12) {
Text("Steps per channel") Text("Steps per channel")
Spacer() .foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.steps, format: .number) TextField("", value: $model.steps, format: .number)
.frame(width: 60) .textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calSteps") .accessibilityIdentifier("calSteps")
} }
HStack { HStack(spacing: 12) {
Text("White patches") Text("White patches")
Spacer() .foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.whitePatches, format: .number) TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60) .textFieldStyle(.roundedBorder)
.frame(width: 70)
} }
if model.colourSpace == .cmyk { if model.colourSpace == .cmyk {
HStack { HStack(spacing: 12) {
Text("Ink-limit exploration") Text("Ink-limit exploration")
Spacer() .foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", text: $model.inkLimit) TextField("", text: $model.inkLimit)
.frame(width: 60) .textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calInkExplore") .accessibilityIdentifier("calInkExplore")
} }
} }
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis) Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("calNeutralEmphasis")
}
} }
Section("Workflow") { // MARK: - Workflow
private var workflowSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 12) { HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() } Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate") .accessibilityIdentifier("btnCalGenerate")
@@ -73,6 +115,8 @@ struct CalibrationView: View {
if let url = model.computedCalURL { if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() } .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle") .accessibilityIdentifier("calApplyToggle")
@@ -81,28 +125,17 @@ struct CalibrationView: View {
.foregroundStyle(.secondary) .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)
}
}
} }
HStack { // MARK: - Log
Spacer()
Button("Return to Profiling") { model.returnToProfiling() } private var logSection: some View {
.accessibilityIdentifier("btnCalReturn") ProcessLogView(
} lines: model.calibrationLog,
.padding(16) minHeight: 80,
} maxHeight: 120,
containerId: "calLogContainer",
logId: "calLog"
)
} }
} }
+22 -2
View File
@@ -74,6 +74,7 @@ struct GamutView: View {
@StateObject private var viewModel: GamutViewModel @StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
@Environment(\.dismiss) private var dismiss
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
init( init(
@@ -94,6 +95,8 @@ struct GamutView: View {
Divider().overlay(Theme.border) Divider().overlay(Theme.border)
statusLine statusLine
inspectPanel inspectPanel
Divider().overlay(Theme.border)
footer
} }
.frame(minWidth: 720, minHeight: 520) .frame(minWidth: 720, minHeight: 520)
.background(Theme.background) .background(Theme.background)
@@ -142,7 +145,7 @@ struct GamutView: View {
Text(layer?.displayName ?? fallback) Text(layer?.displayName ?? fallback)
} }
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
.disabled(layer == nil || viewModel.viewerUnavailable) .disabled(layer == nil)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded") .help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
// macOS 12 puts the identifier on the Toggle's container, an // macOS 12 puts the identifier on the Toggle's container, an
// element that never reports isEnabled — combine so the a11y // element that never reports isEnabled — combine so the a11y
@@ -322,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 // MARK: - TIFF sample sheet
private var tiffPreviewSheet: some View { private var tiffPreviewSheet: some View {
@@ -381,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.installKeyMonitor() context.coordinator.installKeyMonitor()
context.coordinator.installClickGesture() 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 { if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() } DispatchQueue.main.async { onUnavailable() }
} }
+15
View File
@@ -1,8 +1,21 @@
import Combine import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import Metal
import simd 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). /// View model for the native SceneKit gamut viewer (issues #28, #147).
/// ///
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own /// 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) { init(environment: AppEnvironment, profileGamURL: URL? = nil) {
self.environment = environment self.environment = environment
self.profileGamURL = profileGamURL self.profileGamURL = profileGamURL
// Never let the view mount an SCNView without Metal (#147).
viewerUnavailable = !GamutSceneAvailability.isAvailable
loadTask = Task { await load() } loadTask = Task { await load() }
} }
+9
View File
@@ -220,6 +220,15 @@ struct ManageMediaDialog: View {
.accessibilityIdentifier("mediaLibraryList") .accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260) .frame(minHeight: 260)
if let notice = media.manageApplyNotice {
Text(notice)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("manageMediaNotice")
.accessibilityValue(notice)
}
HStack { HStack {
Button("Apply selected") { Button("Apply selected") {
if let id = selection, if let id = selection,
+19 -22
View File
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
@Published var saveMediaApplyCal = false @Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id — roster complete). /// Inline caption inside the capture sheet (no a11y id — roster complete).
@Published var saveMediaError: String? @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 /// Pure flow flag — the manage sheet's "Capture current…" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a /// 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). /// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult @discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool { func apply(_ recipe: MediaRecipe) async -> Bool {
manageApplyNotice = nil
guard let r = try? recipe.validated() else { guard let r = try? recipe.validated() else {
workflow.wizard.showNotice( return failApply("Media recipe is invalid — not applied.", kind: .error)
"Media recipe is invalid — not applied.", kind: .error)
return false
} }
guard let preset = environment.presetStore.all() guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID }) .first(where: { $0.id == r.presetID })
else { else {
workflow.wizard.showNotice( return failApply(
"Preset \(r.presetID) no longer exists — recipe not applied.", "Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error) kind: .error)
return false
} }
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
workflow.wizard.showNotice( return failApply(
"Recipe colour space does not match its preset — not applied.", "Recipe colour space does not match its preset — not applied.",
kind: .error) kind: .error)
return false
} }
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields. // 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. // Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName workflow.wizard.printerName = r.printerDisplayName
var succeeded = true
// Queue: enumerate fresh via the session's serialized path — // Queue: enumerate fresh via the session's serialized path —
// listPrinters uses fixed process ids, so an overlapping // listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a // enumeration would throw duplicateID. An empty result is a
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
workflow.print.selectedPrinter = r.printerID workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities() await workflow.print.reloadSelectedCapabilities()
} else { } else {
workflow.wizard.showNotice( return failApply(
"Printer \(r.printerDisplayName) is not installed.", "Printer \(r.printerDisplayName) is not installed.",
kind: .warning) kind: .warning)
succeeded = false
} }
} else { } else {
workflow.wizard.showNotice( return failApply(
"Could not enumerate printers — queue left unchanged.", "Could not enumerate printers — queue left unchanged.",
kind: .warning) kind: .warning)
succeeded = false
} }
// Calibration — the recipe is authoritative and runs after // Calibration — the recipe is authoritative and runs after
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
guard FileManager.default.fileExists(atPath: calPath) else { guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
workflow.wizard.showNotice( return failApply(
"Calibration file is missing: \(calPath)", kind: .error) "Calibration file is missing: \(calPath)", kind: .error)
refreshStaleness()
return false
} }
do { do {
let staleDays = environment.settingsStore.load().calibrationStaleDays let staleDays = environment.settingsStore.load().calibrationStaleDays
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
} }
} catch { } catch {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.wizard.showNotice( return failApply(
"Could not load calibration: \(error.localizedDescription)", "Could not load calibration: \(error.localizedDescription)",
kind: .error) kind: .error)
refreshStaleness()
return false
} }
} else { } else {
workflow.profile.applyCalibration = false workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath workflow.profile.calibrationFile = calPath
} }
if succeeded {
selectedRecipeID = r.id selectedRecipeID = r.id
workflow.wizard.showNotice("Applied \(r.name)") workflow.wizard.showNotice("Applied \(r.name)")
}
refreshStaleness() 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 // MARK: - Capture
+1
View File
@@ -39,6 +39,7 @@ struct RootView: View {
} }
WizardStageContent(model: model, workflow: workflow) WizardStageContent(model: model, workflow: workflow)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.background(Theme.background) .background(Theme.background)
+13 -14
View File
@@ -69,22 +69,24 @@ struct SettingsView: View {
} }
Section("Verification") { Section("Verification") {
HStack {
Text("Good ΔE ≤")
TextField( TextField(
"2.0", "Good ΔE ≤",
value: $model.settings.deltaEGoodMax, value: $model.settings.deltaEGoodMax,
format: .number format: .number
) )
.frame(width: 60) .accessibilityIdentifier("settingsDeltaEGood")
Text("Warning ΔE ≤")
TextField( TextField(
"5.0", "Warning ΔE ≤",
value: $model.settings.deltaEWarningMax, value: $model.settings.deltaEWarningMax,
format: .number format: .number
) )
.frame(width: 60) .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 ForEach(model.validationErrors, id: \.self) { error in
Text(error) Text(error)
.font(.caption) .font(.caption)
@@ -93,16 +95,12 @@ struct SettingsView: View {
} }
Section("Calibration") { Section("Calibration") {
HStack {
Text("Stale after")
TextField( TextField(
"30", "Stale after (days)",
value: $model.settings.calibrationStaleDays, value: $model.settings.calibrationStaleDays,
format: .number format: .number
) )
.frame(width: 60) .accessibilityIdentifier("settingsCalStaleDays")
Text("days")
}
} }
Section("Profile install") { Section("Profile install") {
@@ -143,6 +141,7 @@ struct SettingsView: View {
} }
} }
} }
.padding(.leading, 45)
Divider() Divider()
@@ -1,4 +1,5 @@
import Foundation import Foundation
import Metal
import XCTest import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)") 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 { func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
let vm = try makeViewModel() let vm = try makeViewModel()
await vm.awaitInitialLoad() await vm.awaitInitialLoad()
@@ -1,4 +1,5 @@
import Foundation import Foundation
import Metal
import XCTest import XCTest
/// Milestone 10 — Issue #147 gamut compare chrome tests. /// Milestone 10 — Issue #147 gamut compare chrome tests.
@@ -9,6 +10,11 @@ import XCTest
@MainActor @MainActor
final class Milestone10GamutCompareUITests: XCTestCase { 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 app: XCUIApplication!
private var testRoot: URL! private var testRoot: URL!
private var binDir: URL! private var binDir: URL!
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
} }
override func tearDown() async throws { override func tearDown() async throws {
// Never leave the gamut sheet up for `terminate()` (#147).
if app != nil, element("btnCloseGamut").exists {
element("btnCloseGamut").click()
}
app?.terminate() app?.terminate()
app = nil app = nil
if let testRoot { if let testRoot {
@@ -104,6 +114,25 @@ final class Milestone10GamutCompareUITests: XCTestCase {
_ = waitFor("gamutView") _ = 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 { func testLayerTogglesExistWithSRGB() throws {
openGamutSheet() openGamutSheet()
@@ -115,6 +144,11 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let compare = waitFor("gamutLayer-compare") let compare = waitFor("gamutLayer-compare")
XCTAssertTrue(compare.exists) XCTAssertTrue(compare.exists)
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load") 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 { func testAddCompareButtonExists() throws {
@@ -128,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let status = waitFor("gamutStatusText") let status = waitFor("gamutStatusText")
let value = status.value as? String ?? "" let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)") XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
closeGamutSheet()
} }
func testCompareGamLoadEnablesToggle() throws { func testCompareGamLoadEnablesToggle() throws {
@@ -142,6 +177,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
// The pre-load placeholder also exists — wait for enabled. // The pre-load placeholder also exists — wait for enabled.
let compare = waitUntilEnabled("gamutLayer-compare") let compare = waitUntilEnabled("gamutLayer-compare")
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load") 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 status = waitFor("gamutStatusText")
let value = status.value as? String ?? "" let value = status.value as? String ?? ""
@@ -149,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let remove = waitFor("btnGamutRemoveCompare") let remove = waitFor("btnGamutRemoveCompare")
XCTAssertTrue(remove.isEnabled) XCTAssertTrue(remove.isEnabled)
closeGamutSheet()
} }
func testOpenProfileRunsIccgamutForCompare() throws { func testOpenProfileRunsIccgamutForCompare() throws {
@@ -164,6 +201,13 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let compare = waitUntilEnabled("gamutLayer-compare") let compare = waitUntilEnabled("gamutLayer-compare")
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut") 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 { func testInspectPanelIdleStableHeight() throws {
@@ -173,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
XCTAssertTrue(panel.exists) XCTAssertTrue(panel.exists)
XCTAssertTrue(element("gamutInspectIdle").exists) XCTAssertTrue(element("gamutInspectIdle").exists)
XCTAssertTrue(element("gamutStatusText").exists) XCTAssertTrue(element("gamutStatusText").exists)
closeGamutSheet()
} }
func testManualLabInspectShowsContainment() throws { func testManualLabInspectShowsContainment() throws {
@@ -192,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)") XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
XCTAssertTrue(element("gamutInspectL").exists) XCTAssertTrue(element("gamutInspectL").exists)
XCTAssertTrue(element("gamutInspectSwatch").exists) XCTAssertTrue(element("gamutInspectSwatch").exists)
closeGamutSheet()
} }
func testResetIdentifierUnchanged() throws { func testResetIdentifierUnchanged() throws {
openGamutSheet() openGamutSheet()
let reset = waitFor("btnResetGamutCamera") let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled) 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)) XCTAssertTrue(apply.waitForExistence(timeout: 10))
apply.click() 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 let text = (notice.value as? String) ?? notice.label
XCTAssertTrue( XCTAssertTrue(
text.contains("is not installed"), text.contains("is not installed"),
+60 -27
View File
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
return recordedLpArgv() return recordedLpArgv()
} }
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
/// moves up, clear of the Dock collision zone at the window's
/// bottom edge (#132).
///
/// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
/// this LazyVGrid, so the scroll is a real drag on the gallery
/// cell's content. A stale/off-screen AX frame resolves to a screen
/// point that can be a Dock icon — a coordinate click there once
/// opened Calendar instead of Print. Callers must click only when
/// the returned element `isHittable`; never coordinate-click a
/// stale frame.
@discardableResult
private func scrollStage2UntilHittable(
_ identifier: String,
timeout: TimeInterval = 20
) -> XCUIElement {
var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let windowBottom = app.windows.firstMatch.frame.maxY
if button.exists, button.isHittable,
button.frame.maxY < windowBottom - 80 {
return button
}
// Grab the upper half of the cell (the TIFF, not the Print
// button / Dock) and drag toward the top of the window.
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
// the Dock zone.
if cell.isHittable {
let start = cell.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.25))
let end = start.withOffset(CGVector(dx: 0, dy: -280))
start.press(forDuration: 0.15, thenDragTo: end)
} else {
// Cell not hit-testable: drag the stage-2 content
// directly — still content, still never scrollBars.
let scrollView = app.scrollViews["stage-2"]
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55))
.press(forDuration: 0.15, thenDragTo:
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier]
}
return button
}
// MARK: - Tests // MARK: - Tests
/// Panel appears after the manifest; refresh populates the printer /// Panel appears after the manifest; refresh populates the printer
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
// The gallery cell's Print button sits at the window's bottom // The gallery cell's Print button sits at the window's bottom
// edge where synthesized scroll-wheel events are inert on the // edge; scroll until it is genuinely hittable (#132). Never
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar // coordinate-click a stale frame — that point can be the Dock.
// thumb instead — a real scroll that re-renders the cell onscreen. let printPage = scrollStage2UntilHittable("btnPrintPage-0")
var printPage = app.buttons["btnPrintPage-0"] guard printPage.isHittable else {
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.
print("AXTREE-BEGIN frame=\(printPage.frame)\n" + print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
"\(app.debugDescription)\nAXTREE-END") "\(app.debugDescription)\nAXTREE-END")
printPage.coordinate(withNormalizedOffset: XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
CGVector(dx: 0.5, dy: 0.5)).click() return
} }
printPage.click()
let argv = waitForLpLine() let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
XCTAssertTrue(argv.contains("page1.tif"), argv) XCTAssertTrue(argv.contains("page1.tif"), argv)
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
} }
} }
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
/// rows with expanding spacers once gave the stage an unbounded ideal
/// width, and window centering shifted the 270 pt sidebar into
/// negative X (issue #163). AX-tree existence checks cannot see that,
/// so assert real frame geometry.
func testCalibrationViewDoesNotOverflowWindow() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
let ret = app.buttons["btnCalReturn"]
XCTAssertTrue(ret.waitForExistence(timeout: 5))
XCTAssertTrue(ret.isHittable)
}
/// "Return to Profiling" is the single Stage 0 exit and carries the
/// cancel-action shortcut, so Escape must dismiss the dashboard too
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
/// runner (m10 phase-08), so the Escape check falls back to the
/// deterministic button tap.
func testCalibrationReturnButtonAndEscapeDismiss() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let returnButton = app.buttons["btnCalReturn"]
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
XCTAssertTrue(returnButton.isHittable)
returnButton.tap()
let stage1 = app.descendants(matching: .any)["stage-1"]
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
// Re-enter and try Escape; fall back to the button where the
// runtime does not deliver typeKey.
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
if !stage1.waitForExistence(timeout: 4) {
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
returnButton.tap()
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
}
}
/// A failing calibration targen surfaces the error through the /// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80). /// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws { func testCalibrationTargenFailureRestoresBasename() throws {
@@ -1,10 +1,15 @@
import Foundation import Foundation
import Metal
import XCTest import XCTest
/// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests. /// Milestone 6 — Issue #28 native SceneKit gamut viewer acceptance tests.
@MainActor @MainActor
final class Milestone6GamutUITests: XCTestCase { final class Milestone6GamutUITests: XCTestCase {
/// Metal on the test host — the app under test runs on the same
/// machine, so this predicts whether the sheet mounts SceneKit.
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
private var app: XCUIApplication! private var app: XCUIApplication!
private var testRoot: URL! private var testRoot: URL!
private var binDir: URL! private var binDir: URL!
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
} }
override func tearDown() async throws { override func tearDown() async throws {
// Never leave the gamut sheet up for `terminate()` (#147).
if app != nil, element("btnCloseGamut").exists {
element("btnCloseGamut").click()
}
app?.terminate() app?.terminate()
app = nil app = nil
if let testRoot { if let testRoot {
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
return el return el
} }
/// Inverse of `waitFor` — polls until the element leaves the tree.
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !element(id).exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
}
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
/// is not stuck behind a key sheet (#147). No-op when already closed.
private func closeGamutSheet() {
let close = element("btnCloseGamut")
guard close.waitForExistence(timeout: 5) else { return }
close.click()
waitForGone("gamutView")
}
/// Build and verify the mock profile, then open the native gamut viewer. /// Build and verify the mock profile, then open the native gamut viewer.
/// The viewer should load both the reference sRGB mesh and the profile /// The viewer should load both the reference sRGB mesh and the profile
/// gamut copied from that reference. /// gamut copied from that reference.
func testViewGamutOpensSceneKitSheet() throws { private func openGamutSheet() {
app.launch() app.launch()
if !app.wait(for: .runningForeground, timeout: 10) { if !app.wait(for: .runningForeground, timeout: 10) {
app.activate() app.activate()
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
waitFor("btnViewGamut").click() waitFor("btnViewGamut").click()
_ = waitFor("gamutView")
}
func testViewGamutOpensSceneKitSheet() throws {
openGamutSheet()
let gamutView = waitFor("gamutView") let gamutView = waitFor("gamutView")
XCTAssertTrue(gamutView.exists) XCTAssertTrue(gamutView.exists)
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
let value = status.value as? String ?? "" let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)") XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
// The fallback banner appears exactly when the host lacks Metal
// — no SCNView is constructed without a GPU (#147).
if hasGPU {
XCTAssertFalse(
element("gamutViewerUnavailable").exists,
"GPU host must mount the SceneKit view, not the fallback")
} else {
_ = waitFor("gamutViewerUnavailable")
}
// The reset button demonstrates that the viewer is interactive. // The reset button demonstrates that the viewer is interactive.
let reset = waitFor("btnResetGamutCamera") let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled) XCTAssertTrue(reset.isEnabled)
closeGamutSheet()
}
/// Clicking Reset drives the live `SCNView` — runs only on Metal
/// hosts, skipped on GPU-less runners so the same suite exercises
/// 3D once CI has a GPU (#147).
func testResetCameraInteractsWithScene() throws {
guard hasGPU else { throw XCTSkip("No Metal") }
openGamutSheet()
let reset = waitFor("btnResetGamutCamera")
reset.click() reset.click()
closeGamutSheet()
} }
} }
+192
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
| File | Use | | File | Use |
|------|-----| |------|-----|
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 | | `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI | | `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS | | `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
+89
View File
@@ -0,0 +1,89 @@
@startuml ICCery-UI-sheets
title Sheets Settings, presets, media, Spot Read, Gamut, project
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "SettingsView.swift" as set {
Argyll dir => TextField+Browse : empty = bundled
Default instrument => Picker : seeds Stage 3 and Spot Read
Enable i1Pro 2 LEDs => Toggle
settingsDeltaEGood => TextField
settingsDeltaEWarning => TextField : must be greater than Good
settingsCalStaleDays => TextField
Install location => Picker User or System
Ask before overwriting => Toggle
Open ColorSync after install => Toggle
Log level => Picker
Open log folder => Button
Cancel / Save => Save stays if validation fails
}
map "Presets PresetDialogs.swift" as pre {
savePresetName => TextField
savePresetDesc => TextField
btnCloseSavePresetDialog => Button
btnConfirmSavePreset => Button : disabled if name empty
presetRow-id => Row : built-in cannot delete
btnImportPreset => Button
btnExportActivePreset => Button : custom selected
btnCloseManagePresetsDialog => Button
}
map "Media MediaLibraryDialogs.swift" as media {
saveMediaName/Paper/Ink => TextField
saveMediaApplyCal => Toggle : disabled if CAL_ or missing file
btnConfirmSaveMedia => Button : name+paper+ink required
mediaLibraryList => List
btnMediaLibraryApply-id => Button per row
manageMediaNotice => Caption : failed Apply in-sheet
btnMediaLibraryApply => Button : disabled if no selection
btnCloseManageMediaDialog => Button
}
map "spotReadView SpotReadView.swift" as spot {
btnSpotDetectInstruments => Button
spotInstrumentSelect => Picker : disabled while running
spotSetDefault => Toggle
btnSpotStart => Button : needs sidecar, cwd, not chartread
btnSpotCalibrate => Button : calibrating
btnSpotTrigger => Button : Read, awaitingStrip
btnSpotStop => Button : while running
btnSpotCopyLab => Button : disabled if no sample
btnSpotExportCsv => Button : disabled if no history
btnCloseSpotRead => Button Esc : dismiss = Stop
}
map "gamutView GamutView.swift" as gam {
gamutLayer-srgb => Toggle : can hide, cannot remove
gamutLayer-profile => Toggle : disabled if no .gam
gamutLayer-compare => Toggle : disabled if no compare
btnGamutAddCompare => Menu
btnGamutRemoveCompare => Button
btnGamutSampleTiff => Button
btnResetGamutCamera => Button R
gamutLabEntryL/A/B => TextField
btnGamutInspectLab => Button
btnCloseGamut => Button Esc
}
map "Project alerts" as proj {
projectNewAlert => Alert : New Cancel / Confirm
Dirty save => Alert : Save / Don't Save / Cancel
projectRelocateSheet => Sheet : cwd missing on Open
}
set -[hidden]down- pre
pre -[hidden]down- media
media -[hidden]down- spot
spot -[hidden]down- gam
gam -[hidden]down- proj
@enduml
+81
View File
@@ -0,0 +1,81 @@
@startuml ICCery-UI-shell
title Shell, sidebar, stepper, File menu
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "Window" as win {
WindowGroup => ICCeryApp.swift : 1280x800 min 1100x700
RootView => RootView.swift : hosts every sheet
noticeText => NoticeBanner : shown if wizard.notice set; auto-hide 6s
}
map "Sidebar header" as hdr {
openSettingsBtn => Button : always / Settings sheet
openAboutBtn => Button : always / About
btnToggleAllHelp => Button : toggles yellow help dots
}
map "Presets" as pre {
presetSelect => Picker.menu : none + preset id; none is not factory reset
btnSavePresetModal => Button : always
btnOpenPresetsDialog => Button : always
}
map "Media library" as med {
mediaSelect => Picker.menu : never reuses presetSelect
mediaRecipeStale => Caption : shown if staleReasons nonempty
btnMediaLibraryCapture => Button : disabled if no Stage 2 printer
btnMediaLibraryManage => Button : always
}
map "Studio (not stepper)" as stu {
btnCalibratePrinter => Button : always; Stage 0
btnViewGamut => Button : sidebar always on; same id as Stage 5
btnSpotRead => Button : disabled if no cwd or chartread running
}
map "Stepper 1-5 disk is truth" as stp {
Stage1 Generate => always
Stage2 Lay out => .ti1
Stage3 Measure => .ti1 AND .ti2
Stage4 Build => .ti3 (not .ti2 alone)
Stage5 Verify => .ti3 AND .icc/.icm
Calibrate => not a stepper row; always available
}
map "Project chip" as chip {
projectChipName => Text : bound name or No project
projectChipPath => Text : shown if bound
projectChipStale => Caption : diskBehindNotes
btnProjectReveal => Button : shown if bound
btnProjectSave => Button : canSave = bound + basename + cwd
btnProjectOpen => Button : shown if not bound
}
map "File menu" as menu {
menuProjectNew => Button Cmd-N
menuProjectOpen => Button Cmd-O
menuProjectRecents => Menu : disabled if recents empty
menuProjectSave => Button Cmd-S : !canSave
menuProjectSaveAs => Button Shift-Cmd-S : basename + cwd
menuProjectReport => Button : !canReport
menuProjectClose => Button : !isBound
}
win -[hidden]down- hdr
hdr -[hidden]down- pre
pre -[hidden]down- med
med -[hidden]down- stu
stu -[hidden]down- stp
stp -[hidden]down- chip
chip -[hidden]down- menu
@enduml
+83
View File
@@ -0,0 +1,83 @@
@startuml ICCery-UI-stage1-2
title Stages 1-2 Generate Target / Lay Out and Print
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "stage-1 Stage1View.swift" as s1 {
colourSpace => Picker.segmented : RGB or CMYK
patchCountPreset => Picker
patchCountCustom => TextField : shown if preset is custom
whitePatches => Stepper 0-50
blackPatches => Stepper 0-50
targetBasename => TextField
btnBrowse => Button
btnSelectWorkDir => Button
btnOpenExisting => Button : .ti1 or .ti2
btn-import-dataset => Button : unlocks stage 4
selectedPathDisplay => Text
targenAdvancedDetails => DisclosureGroup : UI tests pre-expand
btnGenerate => Button : needs basename AND directory
targenLog => ProcessLogView
}
map "Stage 1 Advanced (inside disclosure)" as adv {
targenGreySteps => Toggle+field : field iff on
targenSingleChannelSteps => Toggle+field
targenNeutralSteps => Toggle+field
targenNeutralConcentration => Toggle+Slider 0-1
targenAdaptation => Toggle+Slider 0-1
targenPrecondProfile => TextField
btnBrowsePrecondProfile => Button
targenHighQuality => Toggle
targenAlgorithm => Picker
targenInkLimitGroup => Toggle+field : CMYK only
targenDarkEmphasis => Toggle+Slider 0-3
targenDevicePower => Toggle+Slider 0-3
}
map "stage-2 Stage2View.swift" as s2 {
cmWarningBanner => Banner : always on stage 2
instrumentSelect => Picker : chart code, not USB port
pageSizeSelect => Picker
customPageW / customPageH => TextField : pageSize custom
tiffDpi => Stepper 72-600
printtargLayoutOrder => Picker
printtargCustomSeed => TextField : custom seed order
btnToggleLabelEdit => Button
targetMetadataPrinter => TextField
targetMetadataInkSet => TextField
targetMetadataDriverPaper => TextField
targetMetadataActualPaper => TextField
targetLabelPreview => TextField or Text
btnCreateLayout => Button : disabled if no basename
tiffGallery => Grid : after printtarg
btnPrintPage-N => Button : disabled if no printer
}
map "rawPrintPanel" as prn {
printerSelect => Picker CUPS
printerStatusBadge => Caption
btnRefreshPrinters => Button
btnPrinterProperties => Button : disabled if no printer
printerTraySelect => Picker : if trays exist
printerMediaTypeSelect => Picker : if media types exist
btnOrientPortrait => Button
btnOrientLandscape => Button
btnPrintAll => Button : needs layout AND printer
btnAdvanceToStage3 => Button : needs .ti2 unlock
printNotificationText => Caption : if printNotice set
}
s1 -[hidden]down- adv
adv -[hidden]down- s2
s2 -[hidden]down- prn
@enduml
+84
View File
@@ -0,0 +1,84 @@
@startuml ICCery-UI-stage3-5-cal
title Stages 3-5 and Calibrate Printer
!theme plain
skinparam backgroundColor white
skinparam defaultFontColor black
skinparam defaultFontSize 13
skinparam mapBackgroundColor white
skinparam mapBorderColor #222222
skinparam mapFontColor black
skinparam arrowColor #222222
skinparam shadowing false
hide circle
top to bottom direction
map "stage-3 Stage3View.swift" as s3 {
btnDetectInstruments => Button : disabled while detecting
chartreadInstrumentSelect => Picker.menu
xyTableHint => Caption : if XY instrument
xyTablePanel => Place/Align/Scan/Remove
chartreadPrompt => Text
chartreadLastError => Caption : if notice set
btnStartRead => Button : shown if not running; needs basename+cwd
btnCalibrate => Button : running and calibrating
btnTrigger => Button : running and awaitingStrip
btnDoneReadEarly => Button : awaitingStrip
btnAccept => Button : place / align / continue / warning
btnRetry => Button : error state
btnDoneRead => Button : allStripsRead
btnCancel => Button : while running
swatchGrid => swatch-rowId-loc
btnMeasureAnotherSheet => Button : after a finished pass
btnFinishAndAverage => Button : finished AND at least one pass
}
map "stage-4 Stage4View.swift" as s4 {
colprofAlgorithm => Picker
colprofQuality => Picker
colprofFwa => Picker
colprofFwaCustomPath => TextField : custom spectrum
btnBrowseFwaSp => Button
colprofIlluminant => TextField
colprofObserver => TextField
colprofInputViewCond => TextField
colprofOutputViewCond => TextField
colprofDescription => TextField
colprofCopyright => TextField
colprofApplyCalibration => Toggle
colprofCalibrationFile => TextField : if applyCalibration
btnBrowseCalibrationFile => Button : if applyCalibration
btnCreateProfile => Button : basename + cwd, not running
}
map "stage-5 Stage5View.swift" as s5 {
btnVerifyProfile => Button : needs created profile
btnViewGamut => Button : disabled if no .gam (same id as sidebar)
btnInstallProfile => Button : needs created profile
driftPrinterFilter => Picker
btnExportHistory => Button
btnClearHistory => Button
verificationHistoryTable => Table
driftChart => not interactive
profileOverwriteBtn => Alert : install collision
profileRenameBtn => Alert
profileCancelCollisionBtn => Alert
}
map "stage-cal CalibrationView.swift" as cal {
Colour space => Picker.segmented : no a11y id
calSteps => TextField
White patches => TextField : no a11y id
calInkExplore => TextField : CMYK only
calNeutralEmphasis => Toggle
btnCalGenerate => Button : needs basename + cwd
btnCalLayout => Button : same
btnCalMeasure => Button : needs .ti3
btnCalCompute => Button : .ti3 and not computing
calApplyToggle => Toggle : after curves exist
btnCalReturn => Button Esc : restores original basename
}
s3 -[hidden]down- s4
s4 -[hidden]down- s5
s5 -[hidden]down- cal
@enduml
+12
View File
@@ -0,0 +1,12 @@
# Interactive UI map
PlantUML **maps** (not class diagrams). Class diagrams laid out 6500px wide, so Gitea on a phone showed a sliver of a black arrow and the UML “C” icon.
Each file is one stacked column (~560–780px) that Kroki actually fits in the page.
| Diagram | File | Kroki size |
| --- | --- | --- |
| Shell, sidebar, stepper, File menu | [ui-interactive-map-shell.puml](ui-interactive-map-shell.puml) | 563 × 1367 |
| Stages 1–2 | [ui-interactive-map-stage1-2.puml](ui-interactive-map-stage1-2.puml) | 625 × 1366 |
| Stages 3–5 + Calibrate | [ui-interactive-map-stage3-5-cal.puml](ui-interactive-map-stage3-5-cal.puml) | 568 × 1386 |
| Sheets | [ui-interactive-map-sheets.puml](ui-interactive-map-sheets.puml) | 782 × 1502 |
+2
View File
@@ -21,6 +21,8 @@ targets:
- ICCery.entitlements - ICCery.entitlements
- ICCery.Debug.entitlements - ICCery.Debug.entitlements
- Argyll - Argyll
- dmg-background.png
- dmg-background@2x.png
- path: Resources/Argyll - path: Resources/Argyll
type: folder type: folder
dependencies: dependencies:
+7 -2
View File
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
exit 1 exit 1
fi fi
case "$TAG" in
*prerelease*) PRERELEASE=true ;;
*) PRERELEASE=false ;;
esac
DMG="${1:-}" DMG="${1:-}"
if [ -z "$DMG" ]; then if [ -z "$DMG" ]; then
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)" DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
"$API/repos/$REPO/releases/tags/$TAG" || true)" "$API/repos/$REPO/releases/tags/$TAG" || true)"
if [ "$STATUS" = "404" ]; then if [ "$STATUS" = "404" ]; then
echo "==> Creating release $TAG" echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-X POST "$API/repos/$REPO/releases" \ -X POST "$API/repos/$REPO/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")" -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
fi fi
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2 echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
+17 -9
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# scripts/dmgbuild-settings.py # scripts/dmgbuild-settings.py
# #
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME # dmgbuild settings for ICCery. scripts/package-release.sh exports
# in the environment, or accept the defaults. Background art can be supplied # DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
# later by placing a PNG at Resources/dmg-background.png and setting # HiDPI TIFF). A missing background is a hard error — a grey
# DMG_BACKGROUND. # Finder window is not an acceptable release artefact (#95).
import os import os
import sys import sys
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
files = [app_path] files = [app_path]
# Background art is optional. If the referenced PNG does not exist, fall back # Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
# to a plain window. See docs/23-assets.md for the DMG background spec. # about classic Alias Manager blobs. package-release.sh always passes
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png') # a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
if background and not os.path.exists(background): # .DS_Store) needs Python >= 3.10, which the Monterey runner does not
background = None # have; 1.6.5 + TIFF is what CI can ship (#95).
background = os.environ.get('DMG_BACKGROUND', '')
if not background or not os.path.isfile(background):
sys.stderr.write(
'error: DMG_BACKGROUND must point at an existing image '
'(got %r)\n' % background)
sys.exit(1)
icon = None icon = None
# Window size is enough for the app icon and the Applications alias. # Window size is enough for the app icon and the Applications alias.
# Bitmap is slightly larger than this rect so title-bar chrome on
# 14+ does not crop the wordmark.
window_rect = ((100, 100), (660, 400)) window_rect = ((100, 100), (660, 400))
# Use icon view without extra chrome. # Use icon view without extra chrome.
+66 -6
View File
@@ -2,19 +2,79 @@
# scripts/ensure-host-tools.sh # scripts/ensure-host-tools.sh
# #
# Bootstrap host tools needed by CI on the macOS 12 runner: # Bootstrap host tools needed by CI on the macOS 12 runner:
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current # - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
# formula requires Xcode 15.3, which cannot be installed on macOS 12). # current formula requires Xcode 15.3, which cannot be installed on
# - dmgbuild: via pip3 (used by scripts/package-release.sh). # macOS 12).
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
# build/.venv-dmgbuild so the test job never pip-installs it.
# #
# Safe to run repeatedly: existing tools are left alone. # dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
# offer 1.6.5 and keep a cached venv on that version (#95).
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
#
# Safe to run repeatedly: existing tools are left alone unless the
# dmgbuild pin is not met.
set -eu set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
XCODEGEN_VERSION="2.38.0" XCODEGEN_VERSION="2.38.0"
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}" INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
VENV="$ROOT/build/.venv-dmgbuild"
DMGBUILD_PIN="1.6.7"
echo "==> Ensuring dmgbuild" INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild for arg in "$@"; do
case "$arg" in
--dmgbuild) INSTALL_DMGBUILD=1 ;;
esac
done
if [ "$INSTALL_DMGBUILD" = "1" ]; then
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
mkdir -p "$ROOT/build"
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
# the next pip invocation crashed. Recreate if pip is already dead.
if [ -x "$VENV/bin/python" ] \
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
echo "==> venv pip is broken; recreating $VENV"
rm -rf "$VENV"
fi
if [ ! -x "$VENV/bin/python" ]; then
python3 -m venv "$VENV"
fi
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
PIP_IGNORE_REQUIRES_PYTHON=1
export PIP_IGNORE_REQUIRES_PYTHON
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
"dmgbuild==$DMGBUILD_PIN" \
'ds_store>=1.3.3' \
'mac_alias>=2.2.3'
"$VENV/bin/python" -c 'from importlib.metadata import version
print("dmgbuild", version("dmgbuild"))
print("ds_store", version("ds_store"))
print("mac_alias", version("mac_alias"))
parts=[]
for p in version("dmgbuild").split("."):
try:
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
except ValueError:
parts.append(0)
parts += [0, 0, 0]
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
'
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$VENV/bin" >> "$GITHUB_PATH"
fi
PATH="$VENV/bin:$PATH"
export PATH
else
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
fi
if command -v xcodegen >/dev/null 2>&1; then if command -v xcodegen >/dev/null 2>&1; then
echo "==> xcodegen already on PATH: $(xcodegen --version)" echo "==> xcodegen already on PATH: $(xcodegen --version)"
+28 -7
View File
@@ -103,19 +103,39 @@ EOF
scripts/verify-sidecar-signatures.sh "$APP" scripts/verify-sidecar-signatures.sh "$APP"
fi fi
echo "==> Installing / locating dmgbuild" echo "==> Locating dmgbuild"
if ! command -v dmgbuild >/dev/null 2>&1; then # 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" VENV="$ROOT/build/.venv-dmgbuild"
if [ ! -d "$VENV/bin" ]; then if ! command -v dmgbuild >/dev/null 2>&1; then
python3 -m venv "$VENV" if [ ! -x "$VENV/bin/dmgbuild" ]; then
"$VENV/bin/pip" install --upgrade pip INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
"$VENV/bin/pip" install dmgbuild
fi fi
PATH="$VENV/bin:$PATH" PATH="$VENV/bin:$PATH"
export PATH export PATH
fi fi
if ! command -v dmgbuild >/dev/null 2>&1; then if ! command -v dmgbuild >/dev/null 2>&1; then
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2 echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
exit 1
fi
echo "dmgbuild $(command -v dmgbuild)"
if [ -x "$VENV/bin/python" ]; then
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
fi
PNG1X="$ROOT/Resources/dmg-background.png"
PNG2X="$ROOT/Resources/dmg-background@2x.png"
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
echo "error: missing $PNG1X or $PNG2X" >&2
exit 1
fi
mkdir -p "$ROOT/build"
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
echo "==> Building HiDPI DMG background TIFF"
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
if [ ! -f "$DMG_BACKGROUND" ]; then
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
exit 1 exit 1
fi fi
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
DMG_APP="$APP" \ DMG_APP="$APP" \
DMG_FILENAME="$DMG" \ DMG_FILENAME="$DMG" \
DMG_VOLUME_NAME="$VOLUME_NAME" \ DMG_VOLUME_NAME="$VOLUME_NAME" \
DMG_BACKGROUND="$DMG_BACKGROUND" \
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG" dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
echo "DMG: $PWD/$DMG" echo "DMG: $PWD/$DMG"