Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93acd32b51 | ||
|
|
67cf452e59 | ||
|
|
83ed53c7fc | ||
|
|
cba9476af7 | ||
|
|
288b08e3d9 | ||
|
|
18f1d1ff33 | ||
|
|
9dfa79ebd0 | ||
|
|
1b91b0a94e | ||
|
|
890e7281eb | ||
|
|
ccaad2d940 | ||
|
|
7e5e20cb69 | ||
|
|
e72039c123 | ||
|
|
fa02e774ca | ||
|
|
2a8ad4d5d4 | ||
|
|
c2ac9341c8 | ||
|
|
1c310706e4 | ||
|
|
7f6c47d85c | ||
|
|
3b6323378e | ||
|
|
d37dcedfd9 |
@@ -35,7 +35,8 @@ jobs:
|
|||||||
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
|
# 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'))"
|
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
|
||||||
run: scripts/ensure-host-tools.sh
|
run: scripts/ensure-host-tools.sh
|
||||||
@@ -193,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
|
||||||
|
|||||||
@@ -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
|
# 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,24 @@ 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` 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
|
## Instruments
|
||||||
|
|
||||||
@@ -156,11 +234,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 +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/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)
|
||||||
|
|||||||
@@ -56,10 +56,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
/// 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) {
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
window.setContentSize(NSSize(width: 1280, height: 800))
|
let desired = NSSize(width: 1280, height: 800)
|
||||||
window.contentMinSize = NSSize(width: 1100, height: 700)
|
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()
|
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
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -98,23 +98,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
app.buttons["btnDetectInstruments"].click()
|
app.buttons["btnDetectInstruments"].click()
|
||||||
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
_ = waitFor("chartreadInstrumentSelect", timeout: 20)
|
||||||
|
|
||||||
// Keep Auto (port 1) and start the session.
|
// Wait until Start is enabled before clicking. Existence-only
|
||||||
XCTAssertTrue(app.buttons["btnStartRead"].waitForExistence(timeout: 5))
|
// clicks are no-ops on the disabled control (runs 35251, 35293).
|
||||||
app.buttons["btnStartRead"].click()
|
driveOnePass(startButton: "btnStartRead")
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
// Averaging panel appears with one pass snapshot.
|
// Averaging panel appears with one pass snapshot.
|
||||||
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
_ = waitFor("chartreadAveragingPanel", timeout: 20)
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
# 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
|
if ! command -v dmgbuild >/dev/null 2>&1; then
|
||||||
VENV="$ROOT/build/.venv-dmgbuild"
|
if [ ! -x "$VENV/bin/dmgbuild" ]; then
|
||||||
if [ ! -d "$VENV/bin" ]; then
|
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
|
||||||
python3 -m venv "$VENV"
|
|
||||||
"$VENV/bin/pip" install --upgrade pip
|
|
||||||
"$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"
|
||||||
|
|||||||
Reference in New Issue
Block a user