Compare commits
33
Commits
83b5f6bf75
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fefe98434 | ||
|
|
90d034baab | ||
|
|
8cb79e8ffa | ||
|
|
c1c37bb7d7 | ||
|
|
d2466d9a66 | ||
|
|
4af2d96294 | ||
|
|
d17aa962ce | ||
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 | ||
|
|
1b91b0a94e | ||
|
|
890e7281eb | ||
|
|
ccaad2d940 | ||
|
|
7e5e20cb69 | ||
|
|
e72039c123 | ||
|
|
fa02e774ca | ||
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
ad7b91cf91 | ||
|
|
8b931e3625 | ||
|
|
73b18dec5b | ||
|
|
3bc0d14a34 | ||
|
|
4aa2815c6e | ||
|
|
8596f15d52 | ||
|
|
407136c5a2 | ||
|
|
d37dcedfd9 |
@@ -35,7 +35,8 @@ jobs:
|
||||
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
# prebuilt release instead.
|
||||
# prebuilt release instead. dmgbuild is not installed here — the
|
||||
# test job does not package (#95).
|
||||
- name: Ensure host tools
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: scripts/ensure-host-tools.sh
|
||||
@@ -193,8 +194,10 @@ jobs:
|
||||
|
||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||
# the test job never pip-installs it (#95).
|
||||
- name: Ensure host tools
|
||||
run: scripts/ensure-host-tools.sh
|
||||
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Package release
|
||||
run: scripts/package-release.sh
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# GitHub Actions twin of .gitea/workflows/macos.yml.
|
||||
# Deltas from the Gitea file (everything else is the same jobs/steps):
|
||||
# - runs-on macos-14: github.com retired macos-12. Do not use
|
||||
# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800
|
||||
# window hangs off the virtual display and XCTest marks sidebar
|
||||
# controls (x ≈ -116) as not hittable (run 34864198118).
|
||||
# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner
|
||||
# still uses v3.
|
||||
# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the
|
||||
# private Gitea CA when the runner talks to git.i3omb.com.
|
||||
# - Tag DMGs go to a GitHub Release via `gh` instead of
|
||||
# scripts/attach-release-asset.sh (Gitea /api/v1).
|
||||
name: macOS CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-14
|
||||
env:
|
||||
DERIVED: build/DerivedData-test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Assert Xcode 14+ toolchain
|
||||
run: |
|
||||
line="$(xcodebuild -version | head -1)"
|
||||
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
|
||||
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
|
||||
echo "Unexpected Xcode version: $line" >&2; exit 1
|
||||
fi
|
||||
echo "$line"
|
||||
|
||||
# Tag pushes whose name contains "prerelease" skip the test build and both
|
||||
# test legs: they exist to package a build already validated elsewhere.
|
||||
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
||||
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||
# installed on macOS 12 (#109). The script installs a pinned
|
||||
# prebuilt release instead. dmgbuild is not installed here — the
|
||||
# test job does not package (#95).
|
||||
- name: Ensure host tools
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Generate Xcode project
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: xcodegen generate --spec project.yml
|
||||
|
||||
# Tests only ever run on the runner's own architecture; build
|
||||
# just that slice. Packaging (scripts/package-release.sh) still
|
||||
# produces the universal Release binary.
|
||||
- name: Build for testing (host arch)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
xcodebuild build-for-testing \
|
||||
-scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
-configuration Debug \
|
||||
ARCHS="$(uname -m)" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGN_IDENTITY='-'
|
||||
|
||||
# Xcode embeds the shared ICCeryCore package framework into the app
|
||||
# and the test bundle without signing it. Ad-hoc hosts still require
|
||||
# every loaded dylib to carry a cdhash — dyld killed the test host at
|
||||
# launch (run 31992) — so sign every embedded copy once the build is
|
||||
# done (embed steps run after any build script phase) (#119).
|
||||
- name: Sign package product frameworks
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
|
||||
| while IFS= read -r -d '' fw; do
|
||||
echo "signing $fw"
|
||||
codesign --force --sign - --timestamp=none "$fw"
|
||||
done
|
||||
|
||||
- name: Test unit (ICCeryCoreTests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
|
||||
echo "error: no xctestrun produced by build-for-testing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "xctestrun: $XCTESTRUN"
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-only-testing:ICCeryCoreTests \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED"
|
||||
|
||||
# UI tests need macOS Automation / Accessibility permission on the
|
||||
# runner. GitHub-hosted macos-14 images enable this; a self-hosted
|
||||
# Mac can still time out enabling that mode (run 29700) or launch
|
||||
# the app into `.runningBackground` (run 29804). Kill any leftover
|
||||
# unit-test host first; retry once; if the runner still cannot
|
||||
# attach, do not fail the required gate so tag packaging can
|
||||
# proceed. Real XCTest assertion failures still fail the job.
|
||||
- name: Test UI (ICCeryUITests)
|
||||
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
run: |
|
||||
set -o pipefail
|
||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||
LOG="$DERIVED/ui-test.log"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
run_ui() {
|
||||
local label="$1"
|
||||
shift
|
||||
echo "::group::UI tests $label"
|
||||
set +e
|
||||
xcodebuild test-without-building \
|
||||
-xctestrun "$XCTESTRUN" \
|
||||
-destination 'platform=macOS' \
|
||||
-derivedDataPath "$DERIVED" \
|
||||
"$@" | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
echo "::endgroup::"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
is_runner_attach_failure() {
|
||||
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while [ "$attempt" -le 2 ]; do
|
||||
# Probe one case first. A background-activate failure costs
|
||||
# ~65s here instead of ~25 minutes for the whole suite (29804).
|
||||
if ! run_ui "probe attempt $attempt" \
|
||||
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI probe failed with a real test error" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||
exit 0
|
||||
fi
|
||||
if is_runner_attach_failure; then
|
||||
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
|
||||
pkill -x ICCery 2>/dev/null || true
|
||||
attempt=$((attempt + 1))
|
||||
sleep 8
|
||||
continue
|
||||
fi
|
||||
echo "error: UI tests failed with a real test error" >&2
|
||||
exit 1
|
||||
done
|
||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||
exit 0
|
||||
|
||||
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||
# xcresult on failure — upload it so UI failures can be triaged
|
||||
# without access to the runner (#126).
|
||||
- name: Upload UI test xcresult
|
||||
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-test-xcresult
|
||||
path: build/DerivedData-test/Logs/Test
|
||||
|
||||
package:
|
||||
needs: build-and-test
|
||||
runs-on: macos-14
|
||||
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
|
||||
# see build-and-test for why brew is not used on macOS 12 (#109).
|
||||
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
|
||||
# the test job never pip-installs it (#95).
|
||||
- name: Ensure host tools
|
||||
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
|
||||
|
||||
- name: Package release
|
||||
run: scripts/package-release.sh
|
||||
env:
|
||||
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
|
||||
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
|
||||
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
|
||||
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Upload DMG artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: iccery-dmg
|
||||
path: ICCery-*.dmg
|
||||
|
||||
- name: Attach DMG to GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
case "$TAG" in
|
||||
*prerelease*) PRE_FLAG=--prerelease ;;
|
||||
*) PRE_FLAG= ;;
|
||||
esac
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG
|
||||
fi
|
||||
gh release upload "$TAG" ICCery-*.dmg --clobber
|
||||
@@ -1,45 +1,75 @@
|
||||
# ICCery
|
||||
|
||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
|
||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from
|
||||
chart generation through measurement, `colprof`, verification, and ColorSync
|
||||
install. It is **not** a colour engine.
|
||||
|
||||
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
**End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
|
||||
covers every screen (Getting Started through Troubleshooting). This README is
|
||||
for building, packaging, and contributing.
|
||||
|
||||
All measurement, chart generation, and profile mathematics live in the
|
||||
[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned
|
||||
as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Version | 2.0.0 |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||
| CI | Gitea Actions `macos-12` runner |
|
||||
| Default branch | `develop` |
|
||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — shipped on `develop` |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget — shipped on `develop` (PR #145) |
|
||||
| M10 | Studio workflow (#146–#149) — in flight on `milestone/m10-studio` |
|
||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
|
||||
| M7 | Pre-UAT hardening — shipped |
|
||||
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
|
||||
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
|
||||
| M10 | Studio workflow: 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 |
|
||||
|
||||
## What it does
|
||||
|
||||
The wizard is artefact-gated:
|
||||
|
||||
1. **Stage 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore
|
||||
2. **Stage 1** — `targen` → `.ti1`
|
||||
3. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
||||
4. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
||||
5. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
||||
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||
1. **Stage 1** — `targen` → `.ti1`
|
||||
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
||||
3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
||||
4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
||||
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
||||
|
||||
Plus CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging.
|
||||
Plus:
|
||||
|
||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
||||
- **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename
|
||||
- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv`
|
||||
- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal`
|
||||
- **Spot Read** — live one-patch Lab/XYZ from the instrument
|
||||
- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE
|
||||
- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect
|
||||
- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging
|
||||
- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma)
|
||||
|
||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n,
|
||||
Windows/Linux print trees, in-process Argyll, App Sandbox.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14+
|
||||
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
||||
- Network once, to fetch Argyll sidecars
|
||||
To **run** a packaged build:
|
||||
|
||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
||||
- macOS 12.0 Monterey or later (Intel or Apple silicon)
|
||||
|
||||
To **build** on the supported CI/host floor:
|
||||
|
||||
- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7)
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current
|
||||
formula needs Xcode 15.3; CI installs the pinned zip via
|
||||
`scripts/ensure-host-tools.sh`)
|
||||
- Network once, to fetch Argyll sidecars
|
||||
- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild`
|
||||
(`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`)
|
||||
|
||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in
|
||||
`ICCery.entitlements`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -49,7 +79,7 @@ cd iccery-v2-mac
|
||||
git checkout develop
|
||||
|
||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
||||
make test # xcodegen + xcodebuild build test
|
||||
make test # xcodegen + xcodebuild build test (host arch)
|
||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
```
|
||||
|
||||
@@ -62,9 +92,14 @@ xcodebuild test -scheme ICCery \
|
||||
ARCHS="$(uname -m)"
|
||||
```
|
||||
|
||||
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`, so a plain `xcodebuild test` (and `make test`) builds universal; the `ARCHS="$(uname -m)"` override narrows it to the host slice.
|
||||
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`. CI and `make test` override
|
||||
that with `ARCHS="$(uname -m)"` so unit/UI tests build the host slice only.
|
||||
Fat binaries are `make universal` / `scripts/package-release.sh`.
|
||||
|
||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or
|
||||
`ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts
|
||||
to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if
|
||||
`codesign -dvv` or the `instlist` marker is missing.
|
||||
|
||||
```bash
|
||||
# optional
|
||||
@@ -74,9 +109,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
|
||||
export GITEA_TOKEN=… # private releases
|
||||
```
|
||||
|
||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and
|
||||
`Packages/ICCeryCore/.build`.
|
||||
|
||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
|
||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
|
||||
and regenerate.
|
||||
|
||||
## Release packaging
|
||||
|
||||
@@ -84,11 +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
|
||||
```
|
||||
|
||||
The script builds with a fixed derived data path (`build/DerivedData`),
|
||||
locates `Release/ICCery.app` from it, signs the bundle, recursively verifies
|
||||
every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and
|
||||
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay
|
||||
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed.
|
||||
The script builds with a fixed derived data path (`build/DerivedData`), locates
|
||||
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
|
||||
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
|
||||
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
|
||||
writes `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:
|
||||
|
||||
@@ -110,30 +156,47 @@ Resources/ assets; Argyll reference files (not the tools)
|
||||
Vendor/Argyll/ fetched sidecars (gitignored)
|
||||
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
||||
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
||||
scripts/ensure-host-tools.sh
|
||||
scripts/fetch-argyll.sh
|
||||
scripts/package-release.sh
|
||||
docs/ functional spec + v2 ticket plan
|
||||
```
|
||||
|
||||
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is not in this tree.
|
||||
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
|
||||
not in this tree.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
|
||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`, etc.) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
|
||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
|
||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
|
||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
|
||||
bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
|
||||
is always set.
|
||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools
|
||||
(`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
|
||||
(`printcal`, `applycal`, CUPS) use `runCaptured`. Exclusive `ProcessID`
|
||||
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
|
||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
|
||||
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
|
||||
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
|
||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
|
||||
must not replace the input profile on cancel or non-zero exit.
|
||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
|
||||
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
|
||||
`@Observable`.
|
||||
- **Print.** Unmanaged `lp` with ColorSync suppression
|
||||
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
|
||||
options win over derived CUPS keys. Never `lp -o raw`.
|
||||
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
|
||||
limit. Split large `VStack`/`Group` trees (#146).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# full suite (host arch)
|
||||
# full suite (host arch) — same as CI
|
||||
xcodebuild test -scheme ICCery \
|
||||
-destination 'platform=macOS' \
|
||||
ARCHS="$(uname -m)"
|
||||
|
||||
# to compile-check both slices instead:
|
||||
# fat compile-check (not the default test path):
|
||||
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
|
||||
# examples
|
||||
@@ -143,9 +206,24 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||
-only-testing:ICCeryUITests/Milestone5UITests
|
||||
```
|
||||
|
||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
|
||||
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
|
||||
GitHub) runs `build-and-test` then `package` on
|
||||
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
|
||||
test job and still package. `pull_request` is wired for **`develop` only**.
|
||||
The GitHub file is the same pipeline on `macos-14` (github.com retired
|
||||
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
|
||||
DMGs.
|
||||
|
||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
|
||||
CUPS fixtures live under the test bundles; they must not be treated as proof
|
||||
that a real `.gam` / `.icc` was extracted.
|
||||
|
||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are
|
||||
manual and block release, not compile.
|
||||
|
||||
`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is
|
||||
dropped on a fast mock exit; that is a `ProcessManager` drain race, not a
|
||||
missing fixture.
|
||||
|
||||
## Instruments
|
||||
|
||||
@@ -156,11 +234,18 @@ Detected via bundled `instlist`:
|
||||
- SpyderPrint (`p3`)
|
||||
- SpectroScan (`SS`)
|
||||
- DTP20 / 22 / 41 / 51
|
||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
|
||||
`/spectro\s?scan|i1io/i`
|
||||
|
||||
## Docs
|
||||
|
||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
||||
| Where | Audience |
|
||||
|---|---|
|
||||
| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting |
|
||||
| [`docs/`](docs/README.md) | Functional spec (normative for implementers) |
|
||||
| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules |
|
||||
|
||||
Implementation order in `docs/`:
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
@@ -168,29 +253,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
|
||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
||||
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
|
||||
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
|
||||
| [`docs/23-assets.md`](docs/23-assets.md) | Icons, DMG chrome |
|
||||
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
|
||||
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
|
||||
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
|
||||
|
||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
||||
|
||||
## Git
|
||||
|
||||
```
|
||||
develop
|
||||
└── milestone/m10-studio # M10 integration branch
|
||||
└── feat/<issue>-<slug> # one issue per branch
|
||||
develop # integration; PRs land here unless a milestone branch is announced
|
||||
main # protected release line (PR from develop)
|
||||
feat/<issue>-<slug>
|
||||
fix/<issue>-<slug>
|
||||
```
|
||||
|
||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #146–#149 target `milestone/m10-studio`; `milestone/m10-studio` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
|
||||
branch is used only while that milestone is assembling; `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
|
||||
|
||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
|
||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing
|
||||
and personal evaluation only unless a separate grant says otherwise.
|
||||
|
||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
|
||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay
|
||||
subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning
|
||||
via `$PATH`, is a licence break.
|
||||
|
||||
## Related
|
||||
|
||||
- [User wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
|
||||
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
|
||||
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
||||
|
||||
@@ -6,6 +6,8 @@ import ICCeryCore
|
||||
struct AboutView: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var showingLicenses = false
|
||||
|
||||
private let info = ArtefactFiles.appInfo()
|
||||
|
||||
var body: some View {
|
||||
@@ -54,6 +56,12 @@ struct AboutView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button("View Licenses") {
|
||||
showingLicenses = true
|
||||
}
|
||||
.controlSize(.large)
|
||||
.accessibilityIdentifier("viewLicensesBtn")
|
||||
|
||||
Button("Close") {
|
||||
onClose()
|
||||
}
|
||||
@@ -66,5 +74,8 @@ struct AboutView: View {
|
||||
.background(Theme.panel)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("aboutDialog")
|
||||
.sheet(isPresented: $showingLicenses) {
|
||||
LicenseWindowView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,102 +7,135 @@ struct CalibrationView: View {
|
||||
@ObservedObject var wizard: WizardViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Calibrate Printer")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
Form {
|
||||
Section("Wedge Settings") {
|
||||
Picker("Colour Space", selection: $model.colourSpace) {
|
||||
Text("RGB").tag(ColourSpace.rgb)
|
||||
Text("CMYK").tag(ColourSpace.cmyk)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Steps per channel")
|
||||
Spacer()
|
||||
TextField("", value: $model.steps, format: .number)
|
||||
.frame(width: 60)
|
||||
.accessibilityIdentifier("calSteps")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("White patches")
|
||||
Spacer()
|
||||
TextField("", value: $model.whitePatches, format: .number)
|
||||
.frame(width: 60)
|
||||
}
|
||||
|
||||
if model.colourSpace == .cmyk {
|
||||
HStack {
|
||||
Text("Ink-limit exploration")
|
||||
Spacer()
|
||||
TextField("", text: $model.inkLimit)
|
||||
.frame(width: 60)
|
||||
.accessibilityIdentifier("calInkExplore")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||
}
|
||||
|
||||
Section("Workflow") {
|
||||
HStack(spacing: 12) {
|
||||
Button("Generate Target") { model.generateTarget() }
|
||||
.accessibilityIdentifier("btnCalGenerate")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Create Layout & Print") { model.createLayout() }
|
||||
.accessibilityIdentifier("btnCalLayout")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Measure") { model.measureChart() }
|
||||
.accessibilityIdentifier("btnCalMeasure")
|
||||
.disabled(model.calibrationTi3URL == nil)
|
||||
|
||||
Button("Compute Curves") { model.computeCurves() }
|
||||
.accessibilityIdentifier("btnCalCompute")
|
||||
.disabled(!model.canCompute)
|
||||
}
|
||||
|
||||
if let url = model.computedCalURL {
|
||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||
.accessibilityIdentifier("calApplyToggle")
|
||||
|
||||
Text("Loaded: \(url.lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.calibrationLog.isEmpty {
|
||||
Section("Log") {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.calibrationLog, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 80, maxHeight: 120)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Calibrate Printer")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(Theme.text)
|
||||
wedgeSection
|
||||
workflowSection
|
||||
if !model.calibrationLog.isEmpty { logSection }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Theme.background)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Return to Profiling") { model.returnToProfiling() }
|
||||
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("btnCalReturn")
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("stage-cal")
|
||||
}
|
||||
|
||||
// MARK: - Wedge settings
|
||||
|
||||
private var wedgeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
|
||||
Picker("Colour Space", selection: $model.colourSpace) {
|
||||
Text("RGB").tag(ColourSpace.rgb)
|
||||
Text("CMYK").tag(ColourSpace.cmyk)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 220)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("Steps per channel")
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", value: $model.steps, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
.accessibilityIdentifier("calSteps")
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text("White patches")
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", value: $model.whitePatches, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
}
|
||||
|
||||
if model.colourSpace == .cmyk {
|
||||
HStack(spacing: 12) {
|
||||
Text("Ink-limit exploration")
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(width: 140, alignment: .leading)
|
||||
TextField("", text: $model.inkLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 70)
|
||||
.accessibilityIdentifier("calInkExplore")
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
|
||||
.toggleStyle(.checkbox)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("calNeutralEmphasis")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workflow
|
||||
|
||||
private var workflowSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
|
||||
HStack(spacing: 12) {
|
||||
Button("Generate Target") { model.generateTarget() }
|
||||
.accessibilityIdentifier("btnCalGenerate")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Create Layout & Print") { model.createLayout() }
|
||||
.accessibilityIdentifier("btnCalLayout")
|
||||
.disabled(wizard.basename.isEmpty
|
||||
|| wizard.effectiveWorkingDirectory == nil
|
||||
|| model.isGenerating)
|
||||
|
||||
Button("Measure") { model.measureChart() }
|
||||
.accessibilityIdentifier("btnCalMeasure")
|
||||
.disabled(model.calibrationTi3URL == nil)
|
||||
|
||||
Button("Compute Curves") { model.computeCurves() }
|
||||
.accessibilityIdentifier("btnCalCompute")
|
||||
.disabled(!model.canCompute)
|
||||
}
|
||||
|
||||
if let url = model.computedCalURL {
|
||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||
.toggleStyle(.checkbox)
|
||||
.foregroundStyle(Theme.text)
|
||||
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||
.accessibilityIdentifier("calApplyToggle")
|
||||
|
||||
Text("Loaded: \(url.lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Log
|
||||
|
||||
private var logSection: some View {
|
||||
ProcessLogView(
|
||||
lines: model.calibrationLog,
|
||||
minHeight: 80,
|
||||
maxHeight: 120,
|
||||
containerId: "calLogContainer",
|
||||
logId: "calLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
|
||||
/// not get a window that hangs off-screen — XCTest then reports
|
||||
/// sidebar controls at negative x as not hittable (run 34864198118).
|
||||
private func configureMainWindow(_ window: NSWindow) {
|
||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
||||
let desired = NSSize(width: 1280, height: 800)
|
||||
let minimum = NSSize(width: 1100, height: 700)
|
||||
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
|
||||
?? NSRect(origin: .zero, size: desired)
|
||||
|
||||
window.contentMinSize = NSSize(
|
||||
width: min(minimum.width, visible.width),
|
||||
height: min(minimum.height, visible.height)
|
||||
)
|
||||
window.setContentSize(NSSize(
|
||||
width: min(desired.width, visible.width),
|
||||
height: min(desired.height, max(minimum.height, visible.height - 40))
|
||||
))
|
||||
window.center()
|
||||
|
||||
var frame = window.frame
|
||||
if frame.width > visible.width {
|
||||
frame.size.width = visible.width
|
||||
}
|
||||
if frame.height > visible.height {
|
||||
frame.size.height = visible.height
|
||||
}
|
||||
frame.origin.x = min(
|
||||
max(frame.origin.x, visible.minX),
|
||||
visible.maxX - frame.width
|
||||
)
|
||||
frame.origin.y = min(
|
||||
max(frame.origin.y, visible.minY),
|
||||
visible.maxY - frame.height
|
||||
)
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
|
||||
struct LicenseWindowView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let icceryLicense: String
|
||||
private let argyllLicense: String
|
||||
|
||||
init() {
|
||||
// ICCery license from LICENCE.md (embedded at compile time)
|
||||
self.icceryLicense = """
|
||||
# LICENCE
|
||||
|
||||
**Copyright (c) 2026 Gordon Bolton**
|
||||
**All Rights Reserved.**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||
|
||||
Under this licence, you may **not**:
|
||||
|
||||
* Modify, alter, or create derivative works of the Software.
|
||||
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||
* Use the Software for any commercial or production purpose.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Bundled ArgyllCMS sidecar binaries
|
||||
|
||||
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/Argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||
"""
|
||||
|
||||
// ArgyllCMS license from bundled License.txt
|
||||
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
|
||||
let content = try? String(contentsOf: url, encoding: .utf8) {
|
||||
self.argyllLicense = content
|
||||
} else {
|
||||
self.argyllLicense = """
|
||||
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
|
||||
|
||||
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
|
||||
A copy of the license should be present at Resources/Argyll/License.txt.
|
||||
|
||||
See: https://git.i3omb.com/gronod/argyllcms/releases
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title bar
|
||||
HStack {
|
||||
Text("Licenses & Attribution")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("closeLicenseBtn")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Theme.panel.opacity(0.9))
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Section 1: ICCery License
|
||||
licenseSection(
|
||||
title: "ICCery License",
|
||||
content: icceryLicense,
|
||||
identifier: "icceryLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 2: ArgyllCMS License
|
||||
licenseSection(
|
||||
title: "ArgyllCMS License (AGPLv3)",
|
||||
content: argyllLicense,
|
||||
identifier: "argyllLicenseSection"
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Section 3: Attribution & Links
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Attribution & Links")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("attributionHeader")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
|
||||
destination: URL(string: "https://www.argyllcms.com/")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllUpstreamLink")
|
||||
|
||||
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
|
||||
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("argyllForkLink")
|
||||
}
|
||||
|
||||
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("agplIsolationNote")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("attributionSection")
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 500)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("licenseWindow")
|
||||
}
|
||||
|
||||
private func licenseSection(title: String, content: String, identifier: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier(identifier + "Header")
|
||||
|
||||
Text(content)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier(identifier + "Content")
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,15 @@ struct ManageMediaDialog: View {
|
||||
.accessibilityIdentifier("mediaLibraryList")
|
||||
.frame(minHeight: 260)
|
||||
|
||||
if let notice = media.manageApplyNotice {
|
||||
Text(notice)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier("manageMediaNotice")
|
||||
.accessibilityValue(notice)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Apply selected") {
|
||||
if let id = selection,
|
||||
|
||||
@@ -41,6 +41,9 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
@Published var saveMediaApplyCal = false
|
||||
/// Inline caption inside the capture sheet (no a11y id — roster complete).
|
||||
@Published var saveMediaError: String?
|
||||
/// Last failed Apply while Manage is open. The window banner sits
|
||||
/// behind the sheet on Monterey, so the dialog shows this too (#170).
|
||||
@Published var manageApplyNotice: String?
|
||||
|
||||
/// Pure flow flag — the manage sheet's "Capture current…" asks the
|
||||
/// sheet's `onDismiss` to open the capture sheet, avoiding a
|
||||
@@ -120,24 +123,21 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
/// with warning; the refusal is permanent so re-clicking can't help).
|
||||
@discardableResult
|
||||
func apply(_ recipe: MediaRecipe) async -> Bool {
|
||||
manageApplyNotice = nil
|
||||
guard let r = try? recipe.validated() else {
|
||||
workflow.wizard.showNotice(
|
||||
"Media recipe is invalid — not applied.", kind: .error)
|
||||
return false
|
||||
return failApply("Media recipe is invalid — not applied.", kind: .error)
|
||||
}
|
||||
guard let preset = environment.presetStore.all()
|
||||
.first(where: { $0.id == r.presetID })
|
||||
else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Preset \(r.presetID) no longer exists — recipe not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Recipe colour space does not match its preset — not applied.",
|
||||
kind: .error)
|
||||
return false
|
||||
}
|
||||
|
||||
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
|
||||
@@ -145,8 +145,6 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
// Literal per issue: displayName, not the queue id.
|
||||
workflow.wizard.printerName = r.printerDisplayName
|
||||
|
||||
var succeeded = true
|
||||
|
||||
// Queue: enumerate fresh via the session's serialized path —
|
||||
// listPrinters uses fixed process ids, so an overlapping
|
||||
// enumeration would throw duplicateID. An empty result is a
|
||||
@@ -156,16 +154,14 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
workflow.print.selectedPrinter = r.printerID
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Printer \(r.printerDisplayName) is not installed.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
} else {
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Could not enumerate printers — queue left unchanged.",
|
||||
kind: .warning)
|
||||
succeeded = false
|
||||
}
|
||||
|
||||
// Calibration — the recipe is authoritative and runs after
|
||||
@@ -195,10 +191,8 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
guard FileManager.default.fileExists(atPath: calPath) else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Calibration file is missing: \(calPath)", kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let staleDays = environment.settingsStore.load().calibrationStaleDays
|
||||
@@ -215,23 +209,26 @@ final class MediaLibraryViewModel: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.wizard.showNotice(
|
||||
return failApply(
|
||||
"Could not load calibration: \(error.localizedDescription)",
|
||||
kind: .error)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
workflow.profile.applyCalibration = false
|
||||
workflow.profile.calibrationFile = calPath
|
||||
}
|
||||
|
||||
if succeeded {
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
}
|
||||
selectedRecipeID = r.id
|
||||
workflow.wizard.showNotice("Applied \(r.name)")
|
||||
refreshStaleness()
|
||||
return succeeded
|
||||
return true
|
||||
}
|
||||
|
||||
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
|
||||
manageApplyNotice = text
|
||||
workflow.wizard.showNotice(text, kind: kind)
|
||||
refreshStaleness()
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
@@ -39,6 +39,7 @@ struct RootView: View {
|
||||
}
|
||||
WizardStageContent(model: model, workflow: workflow)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.background(Theme.background)
|
||||
|
||||
@@ -69,22 +69,24 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Verification") {
|
||||
HStack {
|
||||
Text("Good ΔE ≤")
|
||||
TextField(
|
||||
"2.0",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("Warning ΔE ≤")
|
||||
TextField(
|
||||
"5.0",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
}
|
||||
TextField(
|
||||
"Good ΔE ≤",
|
||||
value: $model.settings.deltaEGoodMax,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsDeltaEGood")
|
||||
|
||||
TextField(
|
||||
"Warning ΔE ≤",
|
||||
value: $model.settings.deltaEWarningMax,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsDeltaEWarning")
|
||||
|
||||
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(model.validationErrors, id: \.self) { error in
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
@@ -93,16 +95,12 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Calibration") {
|
||||
HStack {
|
||||
Text("Stale after")
|
||||
TextField(
|
||||
"30",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 60)
|
||||
Text("days")
|
||||
}
|
||||
TextField(
|
||||
"Stale after (days)",
|
||||
value: $model.settings.calibrationStaleDays,
|
||||
format: .number
|
||||
)
|
||||
.accessibilityIdentifier("settingsCalStaleDays")
|
||||
}
|
||||
|
||||
Section("Profile install") {
|
||||
@@ -143,6 +141,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 45)
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ final class AboutHelpUITests: XCTestCase {
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id].firstMatch
|
||||
if inApp.exists { return inApp }
|
||||
// Search all sheets (including nested sheets) for the element.
|
||||
// The license window is a nested sheet (sheet presented from AboutView).
|
||||
for sheet in app.sheets.allElementsBoundByIndex {
|
||||
let inSheet = sheet.descendants(matching: .any)[id].firstMatch
|
||||
if inSheet.exists { return inSheet }
|
||||
}
|
||||
// Fallback to original behavior
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
|
||||
}
|
||||
|
||||
@@ -85,4 +92,139 @@ final class AboutHelpUITests: XCTestCase {
|
||||
"Toggling global help must not reflow the sidebar.")
|
||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||
}
|
||||
|
||||
func testAboutDialogShowsViewLicensesButton() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
_ = waitFor("aboutVersion", timeout: 10)
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
XCTAssertTrue(viewLicensesBtn.exists)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
// License window should open as a sheet
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
XCTAssertTrue(element("licenseWindow").exists)
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
// License window should be dismissed
|
||||
XCTAssertFalse(element("licenseWindow").exists)
|
||||
|
||||
// Close about dialog
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
XCTAssertFalse(element("aboutDialog").exists)
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsICCeryLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ICCery license section exists
|
||||
let icceryLicenseSection = waitFor("icceryLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(icceryLicenseSection.exists)
|
||||
|
||||
// Verify ICCery license content contains key phrases
|
||||
let icceryLicenseContent = element("icceryLicenseSectionContent")
|
||||
XCTAssertTrue(icceryLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = icceryLicenseContent.value as? String ?? ""
|
||||
XCTAssertTrue(licenseText.contains("Copyright (c) 2026 Gordon Bolton"))
|
||||
XCTAssertTrue(licenseText.contains("All Rights Reserved"))
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsArgyllLicense() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify ArgyllCMS license section exists
|
||||
let argyllLicenseSection = waitFor("argyllLicenseSectionHeader", timeout: 5)
|
||||
XCTAssertTrue(argyllLicenseSection.exists)
|
||||
|
||||
// Verify Argyll license content exists (may be fallback if License.txt not bundled)
|
||||
let argyllLicenseContent = element("argyllLicenseSectionContent")
|
||||
XCTAssertTrue(argyllLicenseContent.waitForExistence(timeout: 5))
|
||||
let licenseText = argyllLicenseContent.value as? String ?? ""
|
||||
// Should contain either the actual AGPLv3 license or the fallback notice
|
||||
XCTAssertTrue(licenseText.contains("AGPLv3") || licenseText.contains("GNU Affero General Public License") || licenseText.contains("fetch-argyll"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
|
||||
func testLicenseWindowShowsAttributionLinks() throws {
|
||||
launchApp()
|
||||
|
||||
let openAbout = app.buttons["openAboutBtn"]
|
||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
||||
openAbout.click()
|
||||
|
||||
let viewLicensesBtn = waitFor("viewLicensesBtn", timeout: 10)
|
||||
viewLicensesBtn.click()
|
||||
|
||||
_ = waitFor("licenseWindow", timeout: 10)
|
||||
|
||||
// Verify attribution section exists
|
||||
let attributionHeader = waitFor("attributionHeader", timeout: 5)
|
||||
XCTAssertTrue(attributionHeader.exists)
|
||||
|
||||
// Verify upstream link
|
||||
let upstreamLink = element("argyllUpstreamLink")
|
||||
XCTAssertTrue(upstreamLink.waitForExistence(timeout: 5))
|
||||
let upstreamLabel = upstreamLink.label
|
||||
XCTAssertTrue(upstreamLabel.contains("Graeme Gill") || upstreamLabel.contains("argyllcms.com"))
|
||||
|
||||
// Verify fork link
|
||||
let forkLink = element("argyllForkLink")
|
||||
XCTAssertTrue(forkLink.waitForExistence(timeout: 5))
|
||||
let forkLabel = forkLink.label
|
||||
XCTAssertTrue(forkLabel.contains("Gronod") || forkLabel.contains("git.i3omb.com"))
|
||||
|
||||
// Verify AGPL isolation note
|
||||
let isolationNote = element("agplIsolationNote")
|
||||
XCTAssertTrue(isolationNote.waitForExistence(timeout: 5))
|
||||
let noteText = isolationNote.value as? String ?? ""
|
||||
XCTAssertTrue(noteText.contains("isolated subprocesses") || noteText.contains("AGPLv3 isolation"))
|
||||
|
||||
// Close license window
|
||||
let closeLicenseBtn = waitFor("closeLicenseBtn", timeout: 5)
|
||||
closeLicenseBtn.click()
|
||||
|
||||
let closeAboutBtn = waitFor("closeAboutBtn", timeout: 5)
|
||||
closeAboutBtn.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,9 @@ final class Milestone10MediaLibraryUITests: XCTestCase {
|
||||
XCTAssertTrue(apply.waitForExistence(timeout: 10))
|
||||
apply.click()
|
||||
|
||||
let notice = waitFor("noticeText")
|
||||
// The window banner (`noticeText`) sits behind this sheet on
|
||||
// Monterey (#170). Assert the in-sheet copy instead.
|
||||
let notice = waitFor("manageMediaNotice", timeout: 15)
|
||||
let text = (notice.value as? String) ?? notice.label
|
||||
XCTAssertTrue(
|
||||
text.contains("is not installed"),
|
||||
|
||||
@@ -36,6 +36,7 @@ final class Milestone2UITests: XCTestCase {
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
@@ -218,11 +219,12 @@ final class Milestone2UITests: XCTestCase {
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
|
||||
// Print panel is live from M3; a default printer is selected
|
||||
// so both the all-pages and per-page print buttons are enabled.
|
||||
// Print panel is live from M3. GitHub macos-14 has no system
|
||||
// queues, so mock CUPS (`ICCERY_CUPS_BIN_DIR`) must enumerate
|
||||
// before Print All / per-page enable (run 34867767434).
|
||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
XCTAssertTrue(app.buttons["btnPrintPage-0"].isEnabled)
|
||||
_ = waitUntilEnabled("btnPrintAll", timeout: 15)
|
||||
_ = waitUntilEnabled("btnPrintPage-0", timeout: 15)
|
||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDetectInstruments"].click()
|
||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||
|
||||
// Keep Auto (port 1) and start the session.
|
||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
||||
app.buttons["btnStartRead"].click()
|
||||
|
||||
// Calibrate.
|
||||
let calibrate = element("btnCalibrate")
|
||||
if !calibrate.waitForExistence(timeout: 25) {
|
||||
let error = element("chartreadLastError").label
|
||||
let value = element("chartreadLastError").value as? String ?? "<nil>"
|
||||
XCTFail("No calibrate button. lastError.label='\(error)' value='\(value)'")
|
||||
}
|
||||
app.buttons["btnCalibrate"].click()
|
||||
|
||||
// Trigger each strip until all are read → Done & Save appears.
|
||||
driveStripsUntilDone()
|
||||
XCTAssertTrue(element("btnDoneRead").exists)
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
// Wait until Start is enabled before clicking. Existence-only
|
||||
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||
driveOnePass(startButton: "btnStartRead")
|
||||
|
||||
// Averaging panel appears with one pass snapshot.
|
||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||
@@ -168,15 +154,34 @@ final class Milestone4UITests: XCTestCase {
|
||||
|
||||
/// Runs the mock handheld chartread session to completion
|
||||
/// (start → calibrate → strip A → strip B → Done & Save).
|
||||
///
|
||||
/// `isEnabled` can be true in AX while the SwiftUI action is still
|
||||
/// a no-op (runs 35251, 35443). Re-click until the session is
|
||||
/// actually running (`btnCancel` is shown whenever
|
||||
/// `isChartreadRunning`), then wait for Calibrate.
|
||||
private func driveOnePass(startButton: String) {
|
||||
let start = app.buttons[startButton]
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10))
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline, !start.isEnabled {
|
||||
XCTAssertTrue(start.waitForExistence(timeout: 10), startButton)
|
||||
let enabledBy = Date().addingTimeInterval(10)
|
||||
while Date() < enabledBy, !start.isEnabled {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertTrue(start.isEnabled)
|
||||
start.click()
|
||||
XCTAssertTrue(start.isEnabled, "\(startButton) never enabled")
|
||||
|
||||
let sessionBy = Date().addingTimeInterval(25)
|
||||
while Date() < sessionBy, !chartreadSessionRunning {
|
||||
if start.exists, start.isEnabled {
|
||||
start.click()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
}
|
||||
XCTAssertTrue(
|
||||
chartreadSessionRunning,
|
||||
"\(startButton) click never started chartread; "
|
||||
+ "notice=\(element("noticeText").value as? String ?? "") "
|
||||
+ "start.exists=\(start.exists)"
|
||||
)
|
||||
|
||||
_ = waitFor("btnCalibrate", timeout: 25)
|
||||
app.buttons["btnCalibrate"].click()
|
||||
driveStripsUntilDone()
|
||||
@@ -184,6 +189,14 @@ final class Milestone4UITests: XCTestCase {
|
||||
app.buttons["btnDoneRead"].firstMatch.click()
|
||||
}
|
||||
|
||||
/// `btnCancel` is in the tree for the whole chartread session;
|
||||
/// Calibrate/Trigger only appear after the first classified prompt.
|
||||
private var chartreadSessionRunning: Bool {
|
||||
element("btnCancel").exists
|
||||
|| element("btnCalibrate").exists
|
||||
|| element("btnTrigger").exists
|
||||
}
|
||||
|
||||
/// Clicks Trigger for each remaining strip until `btnDoneRead`
|
||||
/// appears — the button is re-polled each pass so a click that
|
||||
/// races a state transition isn't lost.
|
||||
|
||||
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
|
||||
/// rows with expanding spacers once gave the stage an unbounded ideal
|
||||
/// width, and window centering shifted the 270 pt sidebar into
|
||||
/// negative X (issue #163). AX-tree existence checks cannot see that,
|
||||
/// so assert real frame geometry.
|
||||
func testCalibrationViewDoesNotOverflowWindow() throws {
|
||||
let calButton = app.buttons["btnCalibratePrinter"]
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||
calButton.tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.exists)
|
||||
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
|
||||
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
|
||||
let ret = app.buttons["btnCalReturn"]
|
||||
XCTAssertTrue(ret.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(ret.isHittable)
|
||||
}
|
||||
|
||||
/// "Return to Profiling" is the single Stage 0 exit and carries the
|
||||
/// cancel-action shortcut, so Escape must dismiss the dashboard too
|
||||
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
|
||||
/// runner (m10 phase-08), so the Escape check falls back to the
|
||||
/// deterministic button tap.
|
||||
func testCalibrationReturnButtonAndEscapeDismiss() throws {
|
||||
let calButton = app.buttons["btnCalibratePrinter"]
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
|
||||
calButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
let returnButton = app.buttons["btnCalReturn"]
|
||||
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(returnButton.isHittable)
|
||||
returnButton.tap()
|
||||
|
||||
let stage1 = app.descendants(matching: .any)["stage-1"]
|
||||
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||
|
||||
// Re-enter and try Escape; fall back to the button where the
|
||||
// runtime does not deliver typeKey.
|
||||
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
|
||||
calButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
if !stage1.waitForExistence(timeout: 4) {
|
||||
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
|
||||
returnButton.tap()
|
||||
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
|
||||
/// A failing calibration targen surfaces the error through the
|
||||
/// wizard notice and restores the original basename (issue #80).
|
||||
func testCalibrationTargenFailureRestoresBasename() throws {
|
||||
|
||||
@@ -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
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
|
||||
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
|
||||
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
|
||||
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ targets:
|
||||
- ICCery.entitlements
|
||||
- ICCery.Debug.entitlements
|
||||
- Argyll
|
||||
- dmg-background.png
|
||||
- dmg-background@2x.png
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# scripts/dmgbuild-settings.py
|
||||
#
|
||||
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
|
||||
# in the environment, or accept the defaults. Background art can be supplied
|
||||
# later by placing a PNG at Resources/dmg-background.png and setting
|
||||
# DMG_BACKGROUND.
|
||||
# dmgbuild settings for ICCery. scripts/package-release.sh exports
|
||||
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
|
||||
# HiDPI TIFF). A missing background is a hard error — a grey
|
||||
# Finder window is not an acceptable release artefact (#95).
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
|
||||
|
||||
files = [app_path]
|
||||
|
||||
# Background art is optional. If the referenced PNG does not exist, fall back
|
||||
# to a plain window. See docs/23-assets.md for the DMG background spec.
|
||||
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
|
||||
if background and not os.path.exists(background):
|
||||
background = None
|
||||
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
|
||||
# about classic Alias Manager blobs. package-release.sh always passes
|
||||
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
|
||||
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
|
||||
# have; 1.6.5 + TIFF is what CI can ship (#95).
|
||||
background = os.environ.get('DMG_BACKGROUND', '')
|
||||
if not background or not os.path.isfile(background):
|
||||
sys.stderr.write(
|
||||
'error: DMG_BACKGROUND must point at an existing image '
|
||||
'(got %r)\n' % background)
|
||||
sys.exit(1)
|
||||
|
||||
icon = None
|
||||
|
||||
# Window size is enough for the app icon and the Applications alias.
|
||||
# Bitmap is slightly larger than this rect so title-bar chrome on
|
||||
# 14+ does not crop the wordmark.
|
||||
window_rect = ((100, 100), (660, 400))
|
||||
|
||||
# Use icon view without extra chrome.
|
||||
|
||||
@@ -2,19 +2,79 @@
|
||||
# scripts/ensure-host-tools.sh
|
||||
#
|
||||
# Bootstrap host tools needed by CI on the macOS 12 runner:
|
||||
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
|
||||
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
|
||||
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
|
||||
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
|
||||
# current formula requires Xcode 15.3, which cannot be installed on
|
||||
# macOS 12).
|
||||
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
|
||||
# build/.venv-dmgbuild so the test job never pip-installs it.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone.
|
||||
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
|
||||
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
|
||||
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
|
||||
# offer 1.6.5 and keep a cached venv on that version (#95).
|
||||
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
|
||||
#
|
||||
# Safe to run repeatedly: existing tools are left alone unless the
|
||||
# dmgbuild pin is not met.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
XCODEGEN_VERSION="2.38.0"
|
||||
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
DMGBUILD_PIN="1.6.7"
|
||||
|
||||
echo "==> Ensuring dmgbuild"
|
||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dmgbuild) INSTALL_DMGBUILD=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$INSTALL_DMGBUILD" = "1" ]; then
|
||||
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
|
||||
mkdir -p "$ROOT/build"
|
||||
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
|
||||
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
|
||||
# the next pip invocation crashed. Recreate if pip is already dead.
|
||||
if [ -x "$VENV/bin/python" ] \
|
||||
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||
echo "==> venv pip is broken; recreating $VENV"
|
||||
rm -rf "$VENV"
|
||||
fi
|
||||
if [ ! -x "$VENV/bin/python" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
|
||||
PIP_IGNORE_REQUIRES_PYTHON=1
|
||||
export PIP_IGNORE_REQUIRES_PYTHON
|
||||
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
|
||||
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
|
||||
"dmgbuild==$DMGBUILD_PIN" \
|
||||
'ds_store>=1.3.3' \
|
||||
'mac_alias>=2.2.3'
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version
|
||||
print("dmgbuild", version("dmgbuild"))
|
||||
print("ds_store", version("ds_store"))
|
||||
print("mac_alias", version("mac_alias"))
|
||||
parts=[]
|
||||
for p in version("dmgbuild").split("."):
|
||||
try:
|
||||
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
parts += [0, 0, 0]
|
||||
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
|
||||
'
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$VENV/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
else
|
||||
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
|
||||
fi
|
||||
|
||||
if command -v xcodegen >/dev/null 2>&1; then
|
||||
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||
|
||||
@@ -106,9 +106,19 @@ fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
# Copy License.txt from archive root (same level as bin/) to Vendor/Argyll/ root
|
||||
VENDOR_ROOT="$ROOT/Vendor/Argyll"
|
||||
for license_src in "$EXTRACT"/Argyll_V*/License.txt "$EXTRACT"/License.txt; do
|
||||
if [ -f "$license_src" ]; then
|
||||
cp "$license_src" "$VENDOR_ROOT/License.txt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
# Also remove quarantine from Vendor/Argyll root if License.txt was copied
|
||||
xattr -dr com.apple.quarantine "$VENDOR_ROOT" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script. The tree may nest
|
||||
|
||||
@@ -103,19 +103,39 @@ EOF
|
||||
scripts/verify-sidecar-signatures.sh "$APP"
|
||||
fi
|
||||
|
||||
echo "==> Installing / locating dmgbuild"
|
||||
echo "==> Locating dmgbuild"
|
||||
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
|
||||
# which created build/.venv-dmgbuild and prepended it to PATH. Local
|
||||
# runs bootstrap the same venv if dmgbuild is missing.
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
VENV="$ROOT/build/.venv-dmgbuild"
|
||||
if [ ! -d "$VENV/bin" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install --upgrade pip
|
||||
"$VENV/bin/pip" install dmgbuild
|
||||
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||
fi
|
||||
PATH="$VENV/bin:$PATH"
|
||||
export PATH
|
||||
fi
|
||||
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
|
||||
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "dmgbuild $(command -v dmgbuild)"
|
||||
if [ -x "$VENV/bin/python" ]; then
|
||||
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
|
||||
fi
|
||||
|
||||
PNG1X="$ROOT/Resources/dmg-background.png"
|
||||
PNG2X="$ROOT/Resources/dmg-background@2x.png"
|
||||
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
|
||||
echo "error: missing $PNG1X or $PNG2X" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$ROOT/build"
|
||||
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
|
||||
echo "==> Building HiDPI DMG background TIFF"
|
||||
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
|
||||
if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
|
||||
DMG_APP="$APP" \
|
||||
DMG_FILENAME="$DMG" \
|
||||
DMG_VOLUME_NAME="$VOLUME_NAME" \
|
||||
DMG_BACKGROUND="$DMG_BACKGROUND" \
|
||||
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
|
||||
|
||||
echo "DMG: $PWD/$DMG"
|
||||
|
||||
Reference in New Issue
Block a user