Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4249067345 | ||
|
|
800c980137 | ||
|
|
67fb3a9b37 | ||
|
|
eb1503aba9 | ||
|
|
db47a975f0 | ||
|
|
990c49a436 | ||
|
|
60972a0704 | ||
|
|
aa32cc45d3 | ||
|
|
17c47af9f1 | ||
|
|
5bea928042 | ||
|
|
5c8973de73 | ||
|
|
29ecc79585 | ||
|
|
7c5a5e7798 | ||
|
|
607a4e0df4 | ||
|
|
67199877a2 | ||
|
|
9a6da4648f | ||
|
|
b5250591ea | ||
|
|
c1ec07f726 | ||
|
|
a975f1a4e4 | ||
|
|
7c2282a7dd | ||
|
|
caeafb22be | ||
|
|
aac4f10d23 | ||
|
|
b7ac4f92ed | ||
|
|
3244efd366 | ||
|
|
7a05da3a35 | ||
|
|
5134e82e63 | ||
|
|
7fd93247cb | ||
|
|
a0fcede454 | ||
|
|
97eafd11fb | ||
|
|
ed7487c2b6 | ||
|
|
a2e3f11e70 | ||
|
|
c3c9bbc5ba | ||
|
|
b5683aa36a | ||
|
|
707455dcfb | ||
|
|
17ee5d6717 | ||
|
|
2295bcdbae | ||
|
|
c9bff50a1a | ||
|
|
1b3d3dd821 | ||
|
|
55dee0f0cd | ||
|
|
45fd2b988f | ||
|
|
7c6ad1e6ce | ||
|
|
9857ddb4d0 | ||
|
|
7a6b82816b | ||
|
|
32d2184d2e | ||
|
|
a76120d9f7 |
@@ -21,29 +21,52 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Assert Xcode 14 toolchain
|
- name: Assert Xcode 14+ toolchain
|
||||||
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
|
|
||||||
|
|
||||||
- name: Ensure host tools
|
|
||||||
run: |
|
run: |
|
||||||
command -v xcodegen || brew install xcodegen
|
line="$(xcodebuild -version | head -1)"
|
||||||
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
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"
|
||||||
|
|
||||||
|
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
|
||||||
|
# installed on macOS 12 (#109). The script installs a pinned
|
||||||
|
# prebuilt release instead.
|
||||||
|
- name: Ensure host tools
|
||||||
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
run: xcodegen generate --spec project.yml
|
run: xcodegen generate --spec project.yml
|
||||||
|
|
||||||
- name: Build for testing (universal)
|
# 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)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild build-for-testing \
|
xcodebuild build-for-testing \
|
||||||
-scheme ICCery \
|
-scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
-derivedDataPath "$DERIVED" \
|
-derivedDataPath "$DERIVED" \
|
||||||
-configuration Debug \
|
-configuration Debug \
|
||||||
ARCHS='arm64 x86_64' \
|
ARCHS="$(uname -m)" \
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
ONLY_ACTIVE_ARCH=NO \
|
||||||
CODE_SIGNING_ALLOWED=YES \
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
CODE_SIGN_IDENTITY='-'
|
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
|
||||||
|
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)
|
- name: Test unit (ICCeryCoreTests)
|
||||||
run: |
|
run: |
|
||||||
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
|
||||||
@@ -110,7 +133,8 @@ jobs:
|
|||||||
echo "error: UI probe failed with a real test error" >&2
|
echo "error: UI probe failed with a real test error" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
|
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
|
||||||
|
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if is_runner_attach_failure; then
|
if is_runner_attach_failure; then
|
||||||
@@ -126,6 +150,30 @@ jobs:
|
|||||||
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
echo "warning: skipping UI tests after repeated runner attach/activate failures"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
# XCTest stores the a11y hierarchy snapshot and screenshots in the
|
||||||
|
# xcresult on failure — upload it so UI failures can be triaged
|
||||||
|
# without access to the runner (#126).
|
||||||
|
- name: Prepare Node CA bundle (failure path)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
|
||||||
|
security find-certificate -a -p \
|
||||||
|
/System/Library/Keychains/SystemRootCertificates.keychain \
|
||||||
|
/Library/Keychains/System.keychain \
|
||||||
|
> "$NODE_CA_FILE" 2>/dev/null || true
|
||||||
|
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
|
||||||
|
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload UI test xcresult
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
env:
|
||||||
|
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
|
||||||
|
with:
|
||||||
|
name: ui-test-xcresult
|
||||||
|
path: build/DerivedData-test/Logs/Test
|
||||||
|
|
||||||
package:
|
package:
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-12
|
runs-on: macos-12
|
||||||
@@ -134,6 +182,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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).
|
||||||
|
- name: Ensure host tools
|
||||||
|
run: scripts/ensure-host-tools.sh
|
||||||
|
|
||||||
- name: Package release
|
- name: Package release
|
||||||
run: scripts/package-release.sh
|
run: scripts/package-release.sh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*`
|
|||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
```
|
```
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
|
||||||
codesign -dvv <sidecar>
|
codesign -dvv <sidecar>
|
||||||
```
|
```
|
||||||
|
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||||
|
|
||||||
## Private ColorSync SPI
|
## Private ColorSync SPI
|
||||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
# BUILD-PLAN.md — ICCery v2 Mac
|
# BUILD-PLAN.md — ICCery v2 Mac
|
||||||
|
|
||||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + Later.
|
||||||
|
|
||||||
## Sprint rule
|
## Sprint rule
|
||||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||||
@@ -17,9 +17,14 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
|||||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||||
|
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
|
||||||
|
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
|
||||||
|
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
|
||||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||||
|
|
||||||
Issue **16 is not an M3 or M6 exit gate.**
|
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
|
||||||
|
|
||||||
|
Issue **16 is not an M3, M6, or M10 exit gate.**
|
||||||
|
|
||||||
## Branch taxonomy
|
## Branch taxonomy
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
|
|||||||
|
|
||||||
// MARK: - Shared collection
|
// MARK: - Shared collection
|
||||||
|
|
||||||
|
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
|
||||||
|
/// intervals shrink ~10x — same env convention as `AppPaths.testRoot`.
|
||||||
|
/// Release builds compile the branch out entirely; no static state.
|
||||||
|
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
|
||||||
|
#if DEBUG
|
||||||
|
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
|
||||||
|
return nanos / 10
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return nanos
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancels any previous child with the same id and waits for it to
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
/// `duplicateID` from a leftover process (#50, #52).
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.kill(id: id)
|
await processManager.kill(id: id)
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
while await processManager.isRunning(id), attempts < 30 {
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
|
||||||
attempts += 1
|
attempts += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
|||||||
await processManager.setPreKillHook(id: processId) { [processManager] in
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
if isXY {
|
if isXY {
|
||||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
| 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 on `develop` |
|
||||||
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
| 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` |
|
||||||
| 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
|
||||||
@@ -57,9 +59,11 @@ Equivalent without Make:
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
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.
|
||||||
|
|
||||||
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
|
||||||
@@ -124,10 +128,13 @@ docs/ functional spec + v2 ticket plan
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# full suite (universal)
|
# full suite (host arch)
|
||||||
xcodebuild test -scheme ICCery \
|
xcodebuild test -scheme ICCery \
|
||||||
-destination 'platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
ARCHS="$(uname -m)"
|
||||||
|
|
||||||
|
# to compile-check both slices instead:
|
||||||
|
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||||
|
|
||||||
# examples
|
# examples
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
@@ -171,11 +178,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
|||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop
|
||||||
└── milestone/m8-consolidation # integration branch
|
└── milestone/m10-studio # M10 integration branch
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
└── feat/<issue>-<slug> # one issue per branch
|
||||||
```
|
```
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
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.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||||
|
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||||
|
<key>com.apple.security.device.usb</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Debug only: the shared ICCeryCore package framework embedded in the
|
||||||
|
test products is ad-hoc signed with no Team ID, so hardened-runtime
|
||||||
|
library validation kills the test host at launch (run 31992, #119).
|
||||||
|
Release uses ICCery.entitlements and links the package statically. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -3,8 +3,8 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
/// Stage 0 calibration dashboard (issue #29, docs/07).
|
||||||
struct CalibrationView: View {
|
struct CalibrationView: View {
|
||||||
@Bindable var model: CalibrationViewModel
|
@ObservedObject var model: CalibrationViewModel
|
||||||
@Bindable var wizard: WizardViewModel
|
@ObservedObject var wizard: WizardViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -73,7 +73,7 @@ struct CalibrationView: View {
|
|||||||
|
|
||||||
if let url = model.computedCalURL {
|
if let url = model.computedCalURL {
|
||||||
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
|
||||||
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
|
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
|
||||||
.accessibilityIdentifier("calApplyToggle")
|
.accessibilityIdentifier("calApplyToggle")
|
||||||
|
|
||||||
Text("Loaded: \(url.lastPathComponent)")
|
Text("Loaded: \(url.lastPathComponent)")
|
||||||
@@ -96,7 +96,6 @@ struct CalibrationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
/// Stage 0 calibration workflow: generate wedge, print, measure, and
|
||||||
/// compute `.cal` curves.
|
/// compute `.cal` curves.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class CalibrationViewModel: ObservableObject {
|
||||||
final class CalibrationViewModel {
|
|
||||||
|
|
||||||
let workflow: TargetWorkflowViewModel
|
let workflow: TargetWorkflowViewModel
|
||||||
let profile: ProfileWorkflowViewModel
|
let profile: ProfileWorkflowViewModel
|
||||||
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
|
|||||||
|
|
||||||
// MARK: - Form state
|
// MARK: - Form state
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .cmyk
|
@Published var colourSpace: ColourSpace = .cmyk
|
||||||
var steps: Int = 21
|
@Published var steps: Int = 21
|
||||||
var whitePatches: Int = 4
|
@Published var whitePatches: Int = 4
|
||||||
var includeNeutralEmphasis: Bool = false
|
@Published var includeNeutralEmphasis: Bool = false
|
||||||
var inkLimit: String = "320"
|
@Published var inkLimit: String = "320"
|
||||||
var applyToProfile: Bool = false
|
@Published var applyToProfile: Bool = false
|
||||||
var computedCalURL: URL?
|
@Published var computedCalURL: URL?
|
||||||
var calibrationLog: [String] = []
|
@Published var calibrationLog: [String] = []
|
||||||
var isGenerating = false
|
@Published var isGenerating = false
|
||||||
var isComputing = false
|
@Published var isComputing = false
|
||||||
|
|
||||||
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
|
||||||
self.workflow = workflow
|
self.workflow = workflow
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
|
|||||||
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
|
||||||
/// (blue-yellow) is depth.
|
/// (blue-yellow) is depth.
|
||||||
struct GamutView: View {
|
struct GamutView: View {
|
||||||
@State private var viewModel: GamutViewModel
|
@StateObject private var viewModel: GamutViewModel
|
||||||
@State private var pause: () -> Void = {}
|
@State private var pause: () -> Void = {}
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
init(profileGamURL: URL? = nil) {
|
init(profileGamURL: URL? = nil) {
|
||||||
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
_viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -87,11 +87,6 @@ struct GamutView: View {
|
|||||||
)
|
)
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
.focusEffectDisabled()
|
|
||||||
.onKeyPress(.init("R"), action: {
|
|
||||||
viewModel.resetCamera()
|
|
||||||
return .handled
|
|
||||||
})
|
|
||||||
.onAppear { isFocused = true }
|
.onAppear { isFocused = true }
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
context.coordinator.scnView = scnView
|
context.coordinator.scnView = scnView
|
||||||
context.coordinator.scene = scene
|
context.coordinator.scene = scene
|
||||||
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
|
||||||
|
context.coordinator.installKeyMonitor()
|
||||||
|
|
||||||
return scnView
|
return scnView
|
||||||
}
|
}
|
||||||
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
|
||||||
|
coordinator.removeKeyMonitor()
|
||||||
nsView.isPlaying = false
|
nsView.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject {
|
||||||
weak var scnView: SCNView?
|
weak var scnView: SCNView?
|
||||||
weak var scene: SCNScene?
|
weak var scene: SCNScene?
|
||||||
|
private var keyMonitor: Any?
|
||||||
|
|
||||||
private let profileNode = SCNNode()
|
private let profileNode = SCNNode()
|
||||||
private let referenceGroup = SCNNode()
|
private let referenceGroup = SCNNode()
|
||||||
@@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable {
|
|||||||
scnView?.isPlaying = false
|
scnView?.isPlaying = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local key-down monitor for the R camera-reset shortcut (the
|
||||||
|
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
|
||||||
|
/// events aimed at this view's window are handled; everything
|
||||||
|
/// else passes through untouched.
|
||||||
|
func installKeyMonitor() {
|
||||||
|
guard keyMonitor == nil else { return }
|
||||||
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
|
||||||
|
[weak self] event in
|
||||||
|
guard let self,
|
||||||
|
let scnView = self.scnView,
|
||||||
|
event.window === scnView.window,
|
||||||
|
event.charactersIgnoringModifiers?.uppercased() == "R"
|
||||||
|
else { return event }
|
||||||
|
self.resetCamera()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeKeyMonitor() {
|
||||||
|
if let keyMonitor {
|
||||||
|
NSEvent.removeMonitor(keyMonitor)
|
||||||
|
self.keyMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resetCamera() {
|
func resetCamera() {
|
||||||
guard let scnView else { return }
|
guard let scnView else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
import Observation
|
|
||||||
|
|
||||||
/// View model for the native SceneKit gamut viewer.
|
/// View model for the native SceneKit gamut viewer.
|
||||||
///
|
///
|
||||||
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
|
||||||
/// printer/profile `.gam` from the current working directory.
|
/// printer/profile `.gam` from the current working directory.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class GamutViewModel: ObservableObject {
|
||||||
final class GamutViewModel {
|
|
||||||
|
|
||||||
/// Parsed reference sRGB gamut mesh.
|
/// Parsed reference sRGB gamut mesh.
|
||||||
var sRGBMesh: GamutMesh?
|
@Published var sRGBMesh: GamutMesh?
|
||||||
|
|
||||||
/// Parsed printer/profile gamut mesh.
|
/// Parsed printer/profile gamut mesh.
|
||||||
var profileMesh: GamutMesh?
|
@Published var profileMesh: GamutMesh?
|
||||||
|
|
||||||
/// User-facing status line.
|
/// User-facing status line.
|
||||||
var status = "Loading gamut…"
|
@Published var status = "Loading gamut…"
|
||||||
|
|
||||||
/// Closure injected into the SceneKit view to request a camera reset.
|
/// Closure injected into the SceneKit view to request a camera reset.
|
||||||
var resetCamera: () -> Void = {}
|
@Published var resetCamera: () -> Void = {}
|
||||||
|
|
||||||
private let profileGamURL: URL?
|
private let profileGamURL: URL?
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct ICCeryApp: App {
|
struct ICCeryApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var workflow: TargetWorkflowViewModel
|
@StateObject private var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let environment = AppEnvironment.live()
|
let environment = AppEnvironment.live()
|
||||||
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
|
||||||
try? AppPaths.ensureDirectories()
|
try? AppPaths.ensureDirectories()
|
||||||
// Log level is runtime state — apply persisted settings at
|
// Log level is runtime state — apply persisted settings at
|
||||||
// startup (#158); the Settings sheet re-applies on save.
|
// startup (#158); the Settings sheet re-applies on save.
|
||||||
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
|
||||||
Window("ICCery", id: "main") {
|
// metrics are applied by AppDelegate once the window exists.
|
||||||
|
WindowGroup("ICCery") {
|
||||||
RootView(workflow: workflow)
|
RootView(workflow: workflow)
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1280, height: 800)
|
.commands {
|
||||||
.windowResizability(.contentMinSize)
|
// Single-window app: no File > New window.
|
||||||
.defaultPosition(.center)
|
CommandGroup(replacing: .newItem) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var terminationRequested = false
|
private var terminationRequested = false
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
// SwiftUI `Window` scenes launched by XCTest stay
|
// SwiftUI scenes launched by XCTest stay `.runningBackground`
|
||||||
// `.runningBackground` unless the app takes regular activation
|
// unless the app takes regular activation and orders the window
|
||||||
// and orders the window front (CI run 29804).
|
// front (CI run 29804).
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
for window in NSApp.windows {
|
for window in NSApp.windows {
|
||||||
|
configureMainWindow(window)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
|
||||||
|
private func configureMainWindow(_ window: NSWindow) {
|
||||||
|
window.setContentSize(NSSize(width: 1280, height: 800))
|
||||||
|
window.contentMinSize = NSSize(width: 1100, height: 700)
|
||||||
|
window.center()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dock-click reopen: let the WindowGroup re-show or recreate the
|
||||||
|
/// main window when none are visible.
|
||||||
|
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Stage 3 workflow state and interaction (issues #18–#22).
|
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class MeasurementWorkflowViewModel: ObservableObject {
|
||||||
final class MeasurementWorkflowViewModel {
|
|
||||||
|
|
||||||
// MARK: - Authorities
|
// MARK: - Authorities
|
||||||
|
|
||||||
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Settings-driven thresholds
|
// MARK: - Settings-driven thresholds
|
||||||
|
|
||||||
private(set) var goodMax: Double = 2.0
|
@Published private(set) var goodMax: Double = 2.0
|
||||||
private(set) var warningMax: Double = 5.0
|
@Published private(set) var warningMax: Double = 5.0
|
||||||
private(set) var enableLEDs: Bool = false
|
@Published private(set) var enableLEDs: Bool = false
|
||||||
|
|
||||||
// MARK: - Instrument detection
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
var instruments: [InstrumentDevice] = []
|
@Published var instruments: [InstrumentDevice] = []
|
||||||
var selectedInstrument: InstrumentSelection = .auto
|
@Published var selectedInstrument: InstrumentSelection = .auto
|
||||||
var isDetecting = false
|
@Published var isDetecting = false
|
||||||
var detectionError: String?
|
@Published var detectionError: String?
|
||||||
|
|
||||||
// MARK: - Chartread session
|
// MARK: - Chartread session
|
||||||
|
|
||||||
var isChartreadRunning = false
|
@Published var isChartreadRunning = false
|
||||||
var chartreadState: ChartreadState = .idle
|
@Published var chartreadState: ChartreadState = .idle
|
||||||
var currentPrompt: String?
|
@Published var currentPrompt: String?
|
||||||
var requestedWarningKey: String?
|
@Published var requestedWarningKey: String?
|
||||||
var chartreadLog: [String] = []
|
@Published var chartreadLog: [String] = []
|
||||||
var rows: [ChartreadRow] = []
|
@Published var rows: [ChartreadRow] = []
|
||||||
var swatchRows: [SwatchRow] = []
|
@Published var swatchRows: [SwatchRow] = []
|
||||||
var showRemoveSheetNotice = false
|
@Published var showRemoveSheetNotice = false
|
||||||
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
|
||||||
var chartreadNotice: Notice?
|
@Published var chartreadNotice: Notice?
|
||||||
private var chartreadTask: Task<Void, Never>?
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Averaging
|
// MARK: - Averaging
|
||||||
|
|
||||||
var passSnapshots: [URL] = []
|
@Published var passSnapshots: [URL] = []
|
||||||
var isFinishing = false
|
@Published var isFinishing = false
|
||||||
var finishNotice: Notice?
|
@Published var finishNotice: Notice?
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
self.wizard = wizard
|
self.wizard = wizard
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
||||||
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
||||||
struct SavePresetDialog: View {
|
struct SavePresetDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
|
|||||||
|
|
||||||
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
||||||
struct ManagePresetsDialog: View {
|
struct ManagePresetsDialog: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class PrintSessionViewModel: ObservableObject {
|
||||||
final class PrintSessionViewModel {
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
|
|
||||||
var printers: [Printer] = []
|
@Published var printers: [Printer] = []
|
||||||
var selectedPrinter = ""
|
@Published var selectedPrinter = ""
|
||||||
var printerCaps = PrinterCapabilities()
|
@Published var printerCaps = PrinterCapabilities()
|
||||||
var selectedTray: Int?
|
@Published var selectedTray: Int?
|
||||||
var selectedMediaType: String?
|
@Published var selectedMediaType: String?
|
||||||
var printOrientation = "portrait"
|
@Published var printOrientation = "portrait"
|
||||||
var capturedCupsOptions: [String: String] = [:]
|
@Published var capturedCupsOptions: [String: String] = [:]
|
||||||
var printNotice: Notice?
|
@Published var printNotice: Notice?
|
||||||
var isPrinting = false
|
@Published var isPrinting = false
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
@@ -111,7 +110,8 @@ final class PrintSessionViewModel {
|
|||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
// `defer` cannot mutate isolated state under Swift 5.7
|
||||||
|
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
|
||||||
var printed = 0
|
var printed = 0
|
||||||
for page in result.pages {
|
for page in result.pages {
|
||||||
do {
|
do {
|
||||||
@@ -124,6 +124,7 @@ final class PrintSessionViewModel {
|
|||||||
+ error.localizedDescription
|
+ error.localizedDescription
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,7 @@ final class PrintSessionViewModel {
|
|||||||
autoHideAfter: nil
|
autoHideAfter: nil
|
||||||
)
|
)
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,6 @@ final class PrintSessionViewModel {
|
|||||||
isPrinting = true
|
isPrinting = true
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
defer { self.printTask = nil }
|
|
||||||
do {
|
do {
|
||||||
try await spool(page, index: page.index, pageSize: pageSize)
|
try await spool(page, index: page.index, pageSize: pageSize)
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
@@ -157,6 +158,7 @@ final class PrintSessionViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
isPrinting = false
|
isPrinting = false
|
||||||
|
self.printTask = nil
|
||||||
}
|
}
|
||||||
printTask = task
|
printTask = task
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class ProfileWorkflowViewModel: ObservableObject {
|
||||||
final class ProfileWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 4 form
|
// MARK: - Stage 4 form
|
||||||
|
|
||||||
var algorithm: String = "l" // l | x | X | m
|
@Published var algorithm: String = "l" // l | x | X | m
|
||||||
var quality: String = "m" // l | m | h | u
|
@Published var quality: String = "m" // l | m | h | u
|
||||||
var intent: String = "" // usually empty at Stage 4
|
@Published var intent: String = "" // usually empty at Stage 4
|
||||||
var fwaSelection: ColprofFwaSelection = .none
|
@Published var fwaSelection: ColprofFwaSelection = .none
|
||||||
var fwaCustomPath: String = ""
|
@Published var fwaCustomPath: String = ""
|
||||||
var illuminant: String = ""
|
@Published var illuminant: String = ""
|
||||||
var observer: String = ""
|
@Published var observer: String = ""
|
||||||
var inputViewingCond: String = ""
|
@Published var inputViewingCond: String = ""
|
||||||
var outputViewingCond: String = ""
|
@Published var outputViewingCond: String = ""
|
||||||
var profileDescription: String = ""
|
@Published var profileDescription: String = ""
|
||||||
var copyright: String = ""
|
@Published var copyright: String = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var isColprofRunning = false
|
@Published var isColprofRunning = false
|
||||||
var colprofLog: [String] = []
|
@Published var colprofLog: [String] = []
|
||||||
var colprofProgress: String?
|
@Published var colprofProgress: String?
|
||||||
var createdProfileURL: URL?
|
@Published var createdProfileURL: URL?
|
||||||
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
|
||||||
var createdGamutURL: URL?
|
@Published var createdGamutURL: URL?
|
||||||
|
|
||||||
// MARK: - Stage 4/5 calibration (issue #24)
|
// MARK: - Stage 4/5 calibration (issue #24)
|
||||||
|
|
||||||
var applyCalibration = false
|
@Published var applyCalibration = false
|
||||||
var calibrationFile: String = ""
|
@Published var calibrationFile: String = ""
|
||||||
|
|
||||||
// MARK: - Stage 5 verification (issue #25)
|
// MARK: - Stage 5 verification (issue #25)
|
||||||
|
|
||||||
var profcheckReport: ProfcheckReport?
|
@Published var profcheckReport: ProfcheckReport?
|
||||||
var profcheckWarning: String?
|
@Published var profcheckWarning: String?
|
||||||
var isProfcheckRunning = false
|
@Published var isProfcheckRunning = false
|
||||||
|
|
||||||
// MARK: - History / drift (issue #26)
|
// MARK: - History / drift (issue #26)
|
||||||
|
|
||||||
var verificationHistory: [VerificationRecord] = []
|
@Published var verificationHistory: [VerificationRecord] = []
|
||||||
var driftPrinterFilter: String? = nil
|
@Published var driftPrinterFilter: String? = nil
|
||||||
var driftAlert: String?
|
@Published var driftAlert: String?
|
||||||
var isHistoryStoreError: String?
|
@Published var isHistoryStoreError: String?
|
||||||
|
|
||||||
// MARK: - Install (issue #27)
|
// MARK: - Install (issue #27)
|
||||||
|
|
||||||
var installResult: InstallProfileResult?
|
@Published var installResult: InstallProfileResult?
|
||||||
var showingInstallCollision = false
|
@Published var showingInstallCollision = false
|
||||||
var installCollisionMessage: String = ""
|
@Published var installCollisionMessage: String = ""
|
||||||
var pendingInstallOptions: InstallProfileOptions?
|
var pendingInstallOptions: InstallProfileOptions?
|
||||||
|
|
||||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import ICCeryCore
|
|||||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
/// banner pinned to the top (docs/21 §Shell).
|
/// banner pinned to the top (docs/21 §Shell).
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingAllHelp = false
|
@State private var showingAllHelp = false
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -64,11 +70,11 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Content for the active wizard stage. Isolated into its own view so that
|
/// Content for the active wizard stage. Isolated into its own view so that
|
||||||
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
|
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
|
||||||
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
|
||||||
private struct WizardStageContent: View {
|
private struct WizardStageContent: View {
|
||||||
@Bindable var model: WizardViewModel
|
@ObservedObject var model: WizardViewModel
|
||||||
var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch model.stage {
|
switch model.stage {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ICCeryCore
|
|||||||
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
/// the full v1 field set; ΔE validation shows inline under the fields.
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State var model = SettingsViewModel()
|
@StateObject var model = SettingsViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private static let instruments: [(code: String, label: String)] = [
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
@@ -143,7 +143,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
@@ -6,12 +7,11 @@ import ICCeryCore
|
|||||||
/// validation; the log level is applied live via `LogSink` (#158) and a
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
/// `settingsDidChange` notification fans out to #20.
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class SettingsViewModel: ObservableObject {
|
||||||
final class SettingsViewModel {
|
|
||||||
|
|
||||||
var settings: AppSettings
|
@Published var settings: AppSettings
|
||||||
var validationErrors: [String] = []
|
@Published var validationErrors: [String] = []
|
||||||
var savedFlash = false
|
@Published var savedFlash = false
|
||||||
|
|
||||||
private let store: SettingsStore
|
private let store: SettingsStore
|
||||||
private let sink: LogSink
|
private let sink: LogSink
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ import ICCeryCore
|
|||||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var model: WizardViewModel
|
||||||
|
@ObservedObject private var profile: ProfileWorkflowViewModel
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
@Binding var showingAllHelp: Bool
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
private var model: WizardViewModel { workflow.wizard }
|
init(
|
||||||
|
workflow: TargetWorkflowViewModel,
|
||||||
|
onOpenSettings: @escaping () -> Void,
|
||||||
|
onOpenAbout: @escaping () -> Void,
|
||||||
|
showingAllHelp: Binding<Bool>
|
||||||
|
) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._model = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
self._profile = ObservedObject(wrappedValue: workflow.profile)
|
||||||
|
self.onOpenSettings = onOpenSettings
|
||||||
|
self.onOpenAbout = onOpenAbout
|
||||||
|
self._showingAllHelp = showingAllHelp
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ICCeryCore
|
|||||||
/// docs/08). All documented element ids are wired as accessibility
|
/// docs/08). All documented element ids are wired as accessibility
|
||||||
/// identifiers so the UI-test contract stays stable.
|
/// identifiers so the UI-test contract stays stable.
|
||||||
struct Stage1View: View {
|
struct Stage1View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ import ICCeryCore
|
|||||||
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||||
/// real spooling lands in M3.
|
/// real spooling lands in M3.
|
||||||
struct Stage2View: View {
|
struct Stage2View: View {
|
||||||
@Bindable var workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: nested ObservableObjects are not tracked
|
||||||
|
/// through the parent's `objectWillChange`.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
@State private var printGenerationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(workflow: TargetWorkflowViewModel) {
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -240,7 +252,7 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.accessibilityIdentifier("printerSelect")
|
.accessibilityIdentifier("printerSelect")
|
||||||
.onChange(of: workflow.print.selectedPrinter) { _, _ in
|
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||||
workflow.print.selectedTray = nil
|
workflow.print.selectedTray = nil
|
||||||
workflow.print.selectedMediaType = nil
|
workflow.print.selectedMediaType = nil
|
||||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||||
@@ -328,9 +340,18 @@ struct Stage2View: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("rawPrintPanel")
|
.accessibilityIdentifier("rawPrintPanel")
|
||||||
.task(id: workflow.printtargResult?.pages.count) {
|
.onAppear { schedulePrinterRefresh() }
|
||||||
// Auto-enumerate once a manifest exists and whenever it
|
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||||
// changes (e.g. resume from .ti2).
|
schedulePrinterRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||||
|
/// changes (e.g. resume from .ti2). The explicit task handle keeps
|
||||||
|
/// a superseded run from racing the next one.
|
||||||
|
private func schedulePrinterRefresh() {
|
||||||
|
printGenerationTask?.cancel()
|
||||||
|
printGenerationTask = Task { @MainActor in
|
||||||
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
workflow.print.refreshPrinters()
|
workflow.print.refreshPrinters()
|
||||||
}
|
}
|
||||||
@@ -341,7 +362,16 @@ struct Stage2View: View {
|
|||||||
/// One gallery cell: PNG preview + per-page Print button.
|
/// One gallery cell: PNG preview + per-page Print button.
|
||||||
private struct GalleryPageView: View {
|
private struct GalleryPageView: View {
|
||||||
let page: GalleryPage
|
let page: GalleryPage
|
||||||
let workflow: TargetWorkflowViewModel
|
@ObservedObject var workflow: TargetWorkflowViewModel
|
||||||
|
/// Observed directly: `print` is a nested ObservableObject and its
|
||||||
|
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
|
||||||
|
@ObservedObject private var printSession: PrintSessionViewModel
|
||||||
|
|
||||||
|
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
|
||||||
|
self.page = page
|
||||||
|
self.workflow = workflow
|
||||||
|
self._printSession = ObservedObject(wrappedValue: workflow.print)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||||
struct Stage3View: View {
|
struct Stage3View: View {
|
||||||
@Bindable var model: MeasurementWorkflowViewModel
|
@ObservedObject var model: MeasurementWorkflowViewModel
|
||||||
|
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
|
||||||
|
/// observe it directly so header updates propagate.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: MeasurementWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
||||||
struct Stage4View: View {
|
struct Stage4View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header reads `model.wizard.basename`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -130,16 +138,20 @@ struct Stage4View: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("colprofCopyright")
|
.accessibilityIdentifier("colprofCopyright")
|
||||||
|
|
||||||
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
|
||||||
.accessibilityIdentifier("colprofApplyCalibration")
|
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
||||||
|
.accessibilityIdentifier("colprofApplyCalibration")
|
||||||
|
|
||||||
if model.applyCalibration {
|
if model.applyCalibration {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Calibration .cal file", text: $model.calibrationFile)
|
TextField("Calibration .cal file", text: $model.calibrationFile)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.accessibilityIdentifier("colprofCalibrationFile")
|
.accessibilityIdentifier("colprofCalibrationFile")
|
||||||
Button("Browse…") { model.browseForCalibrationFile() }
|
Button("Browse…") { model.browseForCalibrationFile() }
|
||||||
.accessibilityIdentifier("btnBrowseCalibrationFile")
|
.accessibilityIdentifier("btnBrowseCalibrationFile")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import ICCeryCore
|
|||||||
|
|
||||||
/// Stage 5 — verify the generated profile, track drift, and install.
|
/// Stage 5 — verify the generated profile, track drift, and install.
|
||||||
struct Stage5View: View {
|
struct Stage5View: View {
|
||||||
@Bindable var model: ProfileWorkflowViewModel
|
@ObservedObject var model: ProfileWorkflowViewModel
|
||||||
|
/// Header/buttons read `model.wizard.*`; observe the nested
|
||||||
|
/// ObservableObject directly.
|
||||||
|
@ObservedObject private var wizard: WizardViewModel
|
||||||
|
|
||||||
|
init(model: ProfileWorkflowViewModel) {
|
||||||
|
self.model = model
|
||||||
|
self._wizard = ObservedObject(wrappedValue: model.wizard)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||||
@@ -10,8 +10,7 @@ import ICCeryCore
|
|||||||
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||||
/// coalesced log batches and completion hop back.
|
/// coalesced log batches and completion hop back.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class TargetWorkflowViewModel: ObservableObject {
|
||||||
final class TargetWorkflowViewModel {
|
|
||||||
|
|
||||||
let wizard: WizardViewModel
|
let wizard: WizardViewModel
|
||||||
let environment: AppEnvironment
|
let environment: AppEnvironment
|
||||||
@@ -19,95 +18,95 @@ final class TargetWorkflowViewModel {
|
|||||||
|
|
||||||
// MARK: - Stage 1 form (targen)
|
// MARK: - Stage 1 form (targen)
|
||||||
|
|
||||||
var colourSpace: ColourSpace = .rgb {
|
@Published var colourSpace: ColourSpace = .rgb {
|
||||||
didSet {
|
didSet {
|
||||||
guard colourSpace != oldValue else { return }
|
guard colourSpace != oldValue else { return }
|
||||||
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
||||||
blackPatches = colourSpace == .cmyk ? 0 : 4
|
blackPatches = colourSpace == .cmyk ? 0 : 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var patchPreset: PatchCountPreset = .standard800
|
@Published var patchPreset: PatchCountPreset = .standard800
|
||||||
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||||
var customPatchCount = 2500
|
@Published var customPatchCount = 2500
|
||||||
var whitePatches = 4
|
@Published var whitePatches = 4
|
||||||
var blackPatches = 4
|
@Published var blackPatches = 4
|
||||||
|
|
||||||
// Advanced — each optional flag is enabled + value, so an untouched
|
// Advanced — each optional flag is enabled + value, so an untouched
|
||||||
// control emits nothing (#advanced fields are opt-in).
|
// control emits nothing (#advanced fields are opt-in).
|
||||||
var greyStepsEnabled = false
|
@Published var greyStepsEnabled = false
|
||||||
var greySteps = 5
|
@Published var greySteps = 5
|
||||||
var singleChannelEnabled = false
|
@Published var singleChannelEnabled = false
|
||||||
var singleChannelSteps = 5
|
@Published var singleChannelSteps = 5
|
||||||
var neutralStepsEnabled = false
|
@Published var neutralStepsEnabled = false
|
||||||
var neutralSteps = 3
|
@Published var neutralSteps = 3
|
||||||
var neutralConcEnabled = false
|
@Published var neutralConcEnabled = false
|
||||||
var neutralConcentration = 0.50
|
@Published var neutralConcentration = 0.50
|
||||||
var preconditioningProfile: String?
|
@Published var preconditioningProfile: String?
|
||||||
var highQuality = false
|
@Published var highQuality = false
|
||||||
var adaptationEnabled = false
|
@Published var adaptationEnabled = false
|
||||||
var adaptation = 0.10
|
@Published var adaptation = 0.10
|
||||||
var algorithm: FullSpreadAlgorithm = .ofps
|
@Published var algorithm: FullSpreadAlgorithm = .ofps
|
||||||
var inkLimitEnabled = false
|
@Published var inkLimitEnabled = false
|
||||||
var totalInkLimit = 320
|
@Published var totalInkLimit = 320
|
||||||
var darkEmphasisEnabled = false
|
@Published var darkEmphasisEnabled = false
|
||||||
var darkEmphasis = 1.0
|
@Published var darkEmphasis = 1.0
|
||||||
var devicePowerEnabled = false
|
@Published var devicePowerEnabled = false
|
||||||
var devicePower = 1.0
|
@Published var devicePower = 1.0
|
||||||
|
|
||||||
/// `#targetBasename` — no placeholder is ever invented (#60).
|
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||||
var targetBasename = ""
|
@Published var targetBasename = ""
|
||||||
/// `#selectedPathDisplay` / resolved cwd.
|
/// `#selectedPathDisplay` / resolved cwd.
|
||||||
var targetDirectory: URL?
|
@Published var targetDirectory: URL?
|
||||||
|
|
||||||
// MARK: - Stage 2 form (printtarg)
|
// MARK: - Stage 2 form (printtarg)
|
||||||
|
|
||||||
var instrument: PrintInstrument = .i1
|
@Published var instrument: PrintInstrument = .i1
|
||||||
var pageSize: PageSize = .a4
|
@Published var pageSize: PageSize = .a4
|
||||||
var customPageW = 210.0
|
@Published var customPageW = 210.0
|
||||||
var customPageH = 297.0
|
@Published var customPageH = 297.0
|
||||||
var bitDepth: TiffBitDepth = .eight
|
@Published var bitDepth: TiffBitDepth = .eight
|
||||||
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||||
/// regression must be visible here).
|
/// regression must be visible here).
|
||||||
var tiffDpi = 300
|
@Published var tiffDpi = 300
|
||||||
var layoutOrder: LayoutOrder = .deterministic
|
@Published var layoutOrder: LayoutOrder = .deterministic
|
||||||
var customSeed = 1
|
@Published var customSeed = 1
|
||||||
var labelIsCustom = false
|
@Published var labelIsCustom = false
|
||||||
var customLabel = ""
|
@Published var customLabel = ""
|
||||||
var metaPrinter = ""
|
@Published var metaPrinter = ""
|
||||||
var metaInkSet = ""
|
@Published var metaInkSet = ""
|
||||||
var metaDriverPaper = ""
|
@Published var metaDriverPaper = ""
|
||||||
var metaActualPaper = ""
|
@Published var metaActualPaper = ""
|
||||||
|
|
||||||
// MARK: - Run state
|
// MARK: - Run state
|
||||||
|
|
||||||
var targenRunning = false
|
@Published var targenRunning = false
|
||||||
var targenLog: [String] = []
|
@Published var targenLog: [String] = []
|
||||||
var printtargRunning = false
|
@Published var printtargRunning = false
|
||||||
var printtargLog: [String] = []
|
@Published var printtargLog: [String] = []
|
||||||
var printtargResult: PrinttargResult?
|
@Published var printtargResult: PrinttargResult?
|
||||||
/// Sticky until the target changes: `.ti2` resume landed us on
|
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||||
var resumedFromTi2 = false
|
@Published var resumedFromTi2 = false
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
var presets: [ProfilingPreset] = []
|
@Published var presets: [ProfilingPreset] = []
|
||||||
var selectedPresetID = "none"
|
@Published var selectedPresetID = "none"
|
||||||
var showingSavePreset = false
|
@Published var showingSavePreset = false
|
||||||
var showingManagePresets = false
|
@Published var showingManagePresets = false
|
||||||
var savePresetName = ""
|
@Published var savePresetName = ""
|
||||||
var savePresetDesc = ""
|
@Published var savePresetDesc = ""
|
||||||
|
|
||||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe settings changes.
|
/// across stage switches and can observe settings changes.
|
||||||
var measurement: MeasurementWorkflowViewModel
|
@Published var measurement: MeasurementWorkflowViewModel
|
||||||
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||||
/// across stage switches and can observe preset values.
|
/// across stage switches and can observe preset values.
|
||||||
var profile: ProfileWorkflowViewModel
|
@Published var profile: ProfileWorkflowViewModel
|
||||||
/// Stage 0 calibration workflow.
|
/// Stage 0 calibration workflow.
|
||||||
var calibration: CalibrationViewModel!
|
@Published var calibration: CalibrationViewModel!
|
||||||
/// Stage 2 unmanaged print session.
|
/// Stage 2 unmanaged print session.
|
||||||
var print: PrintSessionViewModel!
|
@Published var print: PrintSessionViewModel!
|
||||||
|
|
||||||
init(environment: AppEnvironment = .live()) {
|
init(environment: AppEnvironment = .live()) {
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
@@ -10,47 +10,46 @@ import ICCeryCore
|
|||||||
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
/// navigation is disk, not buttons.
|
/// navigation is disk, not buttons.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
final class WizardViewModel: ObservableObject {
|
||||||
final class WizardViewModel {
|
|
||||||
|
|
||||||
// MARK: - wizardState fields (persisted)
|
// MARK: - wizardState fields (persisted)
|
||||||
|
|
||||||
var stage: WizardStage {
|
@Published var stage: WizardStage {
|
||||||
didSet { if stage != oldValue { persist() } }
|
didSet { if stage != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||||
var basename: String {
|
@Published var basename: String {
|
||||||
didSet { if basename != oldValue { refreshGating(); persist() } }
|
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||||
var workingDirectory: URL? {
|
@Published var workingDirectory: URL? {
|
||||||
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||||
}
|
}
|
||||||
var printerName: String? {
|
@Published var printerName: String? {
|
||||||
didSet { if printerName != oldValue { persist() } }
|
didSet { if printerName != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
var sessionMode: SessionMode {
|
@Published var sessionMode: SessionMode {
|
||||||
didSet { if sessionMode != oldValue { persist() } }
|
didSet { if sessionMode != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// `profileBasename` may differ after a `.ti3` import (#94).
|
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||||
var profileBasename: String? {
|
@Published var profileBasename: String? {
|
||||||
didSet { if profileBasename != oldValue { persist() } }
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
|
||||||
var calibrationOriginalBasename: String {
|
@Published var calibrationOriginalBasename: String {
|
||||||
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
didSet { if calibrationOriginalBasename != oldValue { persist() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
/// Banner notice currently displayed (`#wizardNotification`).
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
var notice: Notice?
|
@Published var notice: Notice?
|
||||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
private(set) var artefacts = StageArtefacts()
|
@Published private(set) var artefacts = StageArtefacts()
|
||||||
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
/// Whether the 3D gamut viewer sheet is open (issue #28).
|
||||||
var showingGamutViewer = false
|
@Published var showingGamutViewer = false
|
||||||
/// Optional `.gam` URL to show alongside the sRGB reference.
|
/// Optional `.gam` URL to show alongside the sRGB reference.
|
||||||
var gamutProfileURL: URL?
|
@Published var gamutProfileURL: URL?
|
||||||
|
|
||||||
private let stateStore: WizardStateStore
|
private let stateStore: WizardStateStore
|
||||||
private var noticeDismissTask: Task<Void, Never>?
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
|
|||||||
@@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase {
|
|||||||
s.modeResolver = { name in
|
s.modeResolver = { name in
|
||||||
if Self.missing.contains(name) { return nil }
|
if Self.missing.contains(name) { return nil }
|
||||||
Self.currentSymbol = name
|
Self.currentSymbol = name
|
||||||
|
// `Self` inside a @convention(c) closure is a dynamic-Self
|
||||||
|
// capture — spell the (final) class name instead.
|
||||||
return { _, modeArg in
|
return { _, modeArg in
|
||||||
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
ColorSyncSuppressorTests.recorded.append(
|
||||||
if let ok = Self.succeeding,
|
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
|
||||||
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
if let ok = ColorSyncSuppressorTests.succeeding,
|
||||||
|
ColorSyncSuppressorTests.currentSymbol == ok.0,
|
||||||
|
(modeArg as String) == ok.1 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ final class ProcessRunSupportTests: XCTestCase {
|
|||||||
setRunning: { running.append($0) },
|
setRunning: { running.append($0) },
|
||||||
resetLog: { resets += 1 },
|
resetLog: { resets += 1 },
|
||||||
onLog: { batch in
|
onLog: { batch in
|
||||||
MainActor.assertIsolated()
|
// MainActor.assertIsolated() needs Swift 5.9; the runner is
|
||||||
|
// on Xcode 14.2 (Swift 5.7) (#115).
|
||||||
|
XCTAssertTrue(Thread.isMainThread)
|
||||||
received.append(contentsOf: batch)
|
received.append(contentsOf: batch)
|
||||||
}
|
}
|
||||||
) { onLog in
|
) { onLog in
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
launchApp()
|
launchApp()
|
||||||
|
|
||||||
let openAbout = app.buttons["openAboutBtn"]
|
let openAbout = app.buttons["openAboutBtn"]
|
||||||
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
|
if !openAbout.waitForExistence(timeout: 10) {
|
||||||
|
// CI triage (#128): print the a11y tree so an empty or
|
||||||
|
// unexpected hierarchy shows up directly in the job log.
|
||||||
|
print("AXTREE-BEGIN windows=\(app.windows.count)\n\(app.debugDescription)\nAXTREE-END")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(openAbout.exists)
|
||||||
openAbout.click()
|
openAbout.click()
|
||||||
|
|
||||||
_ = waitFor("aboutVersion", timeout: 10)
|
_ = waitFor("aboutVersion", timeout: 10)
|
||||||
@@ -64,14 +69,20 @@ final class AboutHelpUITests: XCTestCase {
|
|||||||
let toggle = app.buttons["btnToggleAllHelp"]
|
let toggle = app.buttons["btnToggleAllHelp"]
|
||||||
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
|
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
|
// SDK 13.1 emits no AXGroup for the sidebar root, and an
|
||||||
let before = sidebar.frame
|
// identifier on the container clobbers child identifiers
|
||||||
|
// (#130) — measure a stable sidebar child instead. Query the
|
||||||
|
// pop-up by type: the Picker's "Preset" label inherits the same
|
||||||
|
// identifier, so an .any query matches twice.
|
||||||
|
let sidebarChild = app.popUpButtons["presetSelect"]
|
||||||
|
XCTAssertTrue(sidebarChild.waitForExistence(timeout: 10))
|
||||||
|
let before = sidebarChild.frame
|
||||||
|
|
||||||
toggle.click()
|
toggle.click()
|
||||||
let after = sidebar.frame
|
let after = sidebarChild.frame
|
||||||
|
|
||||||
XCTAssertEqual(before.size.height, after.size.height,
|
XCTAssertEqual(before, after,
|
||||||
"Toggling global help must not reflow the sidebar height.")
|
"Toggling global help must not reflow the sidebar.")
|
||||||
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assert an element stays absent after a short dwell — unlike
|
||||||
|
/// `waitForExistence`, which always burns its full timeout on the
|
||||||
|
/// negative path.
|
||||||
|
private func assertAbsent(_ el: XCUIElement, dwell: TimeInterval = 0.5,
|
||||||
|
_ message: String = "expected element to stay absent",
|
||||||
|
file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(dwell))
|
||||||
|
XCTAssertFalse(el.exists, message, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
private func staticText(_ exact: String) -> XCUIElement {
|
private func staticText(_ exact: String) -> XCUIElement {
|
||||||
let inApp = app.staticTexts[exact]
|
let inApp = app.staticTexts[exact]
|
||||||
if inApp.exists { return inApp }
|
if inApp.exists { return inApp }
|
||||||
@@ -316,7 +326,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
|
assertAbsent(staticText("UI Test Preset"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export a preset to JSON and re-import it (issue #11).
|
/// Export a preset to JSON and re-import it (issue #11).
|
||||||
@@ -348,7 +358,7 @@ final class Milestone2UITests: XCTestCase {
|
|||||||
let deleteButtons = buttonsMatching(
|
let deleteButtons = buttonsMatching(
|
||||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||||
deleteButtons.firstMatch.click()
|
deleteButtons.firstMatch.click()
|
||||||
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
|
assertAbsent(staticText("RoundTrip"))
|
||||||
|
|
||||||
// Copy the export to the import path so the hook picks it up.
|
// Copy the export to the import path so the hook picks it up.
|
||||||
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
||||||
|
|||||||
@@ -198,7 +198,37 @@ final class Milestone3UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
|
||||||
app.buttons["btnPrintPage-0"].click()
|
// The gallery cell's Print button sits at the window's bottom
|
||||||
|
// edge where synthesized scroll-wheel events are inert on the
|
||||||
|
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
|
||||||
|
// thumb instead — a real scroll that re-renders the cell onscreen.
|
||||||
|
var printPage = app.buttons["btnPrintPage-0"]
|
||||||
|
let scrollDeadline = Date().addingTimeInterval(15)
|
||||||
|
while !printPage.isHittable, Date() < scrollDeadline {
|
||||||
|
let scroller = app.scrollBars.allElementsBoundByIndex
|
||||||
|
.first { $0.frame.height > $0.frame.width }
|
||||||
|
if let scroller {
|
||||||
|
scroller.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.1))
|
||||||
|
.press(forDuration: 0.1, thenDragTo:
|
||||||
|
scroller.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.6)))
|
||||||
|
} else {
|
||||||
|
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
printPage = app.buttons["btnPrintPage-0"]
|
||||||
|
}
|
||||||
|
if printPage.isHittable {
|
||||||
|
printPage.click()
|
||||||
|
} else {
|
||||||
|
// LazyVGrid cells can report a stale a11y frame — click the
|
||||||
|
// point directly; the lp argv assert below still verifies.
|
||||||
|
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
|
||||||
|
"\(app.debugDescription)\nAXTREE-END")
|
||||||
|
printPage.coordinate(withNormalizedOffset:
|
||||||
|
CGVector(dx: 0.5, dy: 0.5)).click()
|
||||||
|
}
|
||||||
let argv = waitForLpLine()
|
let argv = waitForLpLine()
|
||||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||||
|
|||||||
@@ -111,16 +111,9 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
|
|
||||||
// Trigger strip A.
|
// Trigger each strip until all are read → Done & Save appears.
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
driveStripsUntilDone()
|
||||||
app.buttons["btnTrigger"].click()
|
XCTAssertTrue(element("btnDoneRead").exists)
|
||||||
|
|
||||||
// Trigger strip B.
|
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
|
|
||||||
// All strips read → Done & Save appears.
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
|
|
||||||
// Averaging panel appears with one pass snapshot.
|
// Averaging panel appears with one pass snapshot.
|
||||||
@@ -186,11 +179,21 @@ final class Milestone4UITests: XCTestCase {
|
|||||||
start.click()
|
start.click()
|
||||||
_ = waitFor("btnCalibrate", timeout: 25)
|
_ = waitFor("btnCalibrate", timeout: 25)
|
||||||
app.buttons["btnCalibrate"].click()
|
app.buttons["btnCalibrate"].click()
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
driveStripsUntilDone()
|
||||||
app.buttons["btnTrigger"].click()
|
XCTAssertTrue(element("btnDoneRead").exists)
|
||||||
_ = waitFor("btnTrigger", timeout: 20)
|
|
||||||
app.buttons["btnTrigger"].click()
|
|
||||||
_ = waitFor("btnDoneRead", timeout: 20)
|
|
||||||
app.buttons["btnDoneRead"].firstMatch.click()
|
app.buttons["btnDoneRead"].firstMatch.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private func driveStripsUntilDone() {
|
||||||
|
let deadline = Date().addingTimeInterval(40)
|
||||||
|
while !element("btnDoneRead").exists, Date() < deadline {
|
||||||
|
if app.buttons["btnTrigger"].waitForExistence(timeout: 10) {
|
||||||
|
app.buttons["btnTrigger"].click()
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ final class Milestone6CalibrationUITests: XCTestCase {
|
|||||||
// After generation the wizard should advance to Stage 2 (layout) because
|
// After generation the wizard should advance to Stage 2 (layout) because
|
||||||
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
// a CAL_ .ti1 now exists and the session is in calibration mode.
|
||||||
let layout = app.buttons["btnCreateLayout"]
|
let layout = app.buttons["btnCreateLayout"]
|
||||||
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
if !layout.waitForExistence(timeout: 25) {
|
||||||
|
// The generate tap can be dropped while the dashboard is still
|
||||||
|
// settling after the stage transition; retry once before failing.
|
||||||
|
if calGenerate.waitForExistence(timeout: 2) {
|
||||||
|
calGenerate.tap()
|
||||||
|
}
|
||||||
|
XCTAssertTrue(layout.waitForExistence(timeout: 25))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failing calibration targen surfaces the error through the
|
/// A failing calibration targen surfaces the error through the
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Do not create issues until labels and milestones exist.
|
|||||||
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
- Artefact gating on disk; atomic writes (`.tmp` + rename); user strings via SwiftUI `Text` only.
|
||||||
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
- Branching: `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`; PRs via Gitea MCP.
|
||||||
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
- Labels: every issue/PR has `Project/ICCery-v2` + one `Feature/*` or `Bug/*` + `Priority/*`.
|
||||||
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`; sidecar `codesign -dvv`.
|
- Verify: `xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"` (host arch; universal reserved for release packaging); sidecar `codesign -dvv`.
|
||||||
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
- Private ColorSync SPI: 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||||
|
|
||||||
### `BUILD-PLAN.md`
|
### `BUILD-PLAN.md`
|
||||||
@@ -566,7 +566,7 @@ Labels: `Feature/DevOps`, `Priority/High`
|
|||||||
Milestone: M6
|
Milestone: M6
|
||||||
|
|
||||||
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
- **Self-hosted Mac runner** (Gitea has no `macos-latest` unless you attach one). Optional GitHub Actions mirror.
|
||||||
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO` → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
- Pipeline: `fetch-argyll` → ad-hoc `codesign -s -` + `codesign -dvv` on every sidecar Mach-O (hard fail) → `xcodebuild build test -scheme ICCery ARCHS="$(uname -m)"` (host arch; the dmgbuild leg still builds universal) → unit + mock fixtures (#215) → **dmgbuild** with background art (**not** Finder AppleScript, #189) → upload artefact.
|
||||||
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
- App signing: Developer ID + **notarize/staple** for the `.app` / `.dmg`. Sidecars remain **ad-hoc** inside the bundle (#165). These are two different gates — do not conflate.
|
||||||
- Confirm entitlements: sandbox **false**.
|
- Confirm entitlements: sandbox **false**.
|
||||||
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
- Spec: [04](04-argyll-binaries.md) §0.6, [05](05-argyll-fork.md) §8–9, [23](23-assets.md), [24](24-issues-invariants.md).
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Unsigned CI artefacts (e.g. a `.zip` from a non-notarized workflow run) are **no
|
|||||||
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
scripts/fetch-argyll.sh # populates Vendor/Argyll and signs sidecars
|
||||||
xcodegen generate --project .
|
xcodegen generate --project .
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
|
ARCHS="$(uname -m)" \
|
||||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+10
@@ -19,6 +19,7 @@ targets:
|
|||||||
- path: Resources
|
- path: Resources
|
||||||
excludes:
|
excludes:
|
||||||
- ICCery.entitlements
|
- ICCery.entitlements
|
||||||
|
- ICCery.Debug.entitlements
|
||||||
- Argyll
|
- Argyll
|
||||||
- path: Resources/Argyll
|
- path: Resources/Argyll
|
||||||
type: folder
|
type: folder
|
||||||
@@ -62,6 +63,15 @@ targets:
|
|||||||
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
OTHER_SWIFT_FLAGS: ["$(inherited)", "-strict-concurrency=minimal"]
|
||||||
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
MACOSX_DEPLOYMENT_TARGET: "12.0"
|
||||||
ARCHS: "$(ARCHS_STANDARD)"
|
ARCHS: "$(ARCHS_STANDARD)"
|
||||||
|
# Debug builds sign ad-hoc; hardened-runtime library validation would
|
||||||
|
# reject the embedded ICCeryCore package framework (no Team ID) when
|
||||||
|
# the test host launches (run 31992, #119). DISABLE_LIBRARY_VALIDATION
|
||||||
|
# does not inject the entitlement on Xcode 14.2, so use a dedicated
|
||||||
|
# Debug entitlements file. Release keeps validation and links the
|
||||||
|
# package statically anyway.
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.Debug.entitlements
|
||||||
|
|
||||||
ICCeryCoreTests:
|
ICCeryCoreTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 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).
|
||||||
|
#
|
||||||
|
# Safe to run repeatedly: existing tools are left alone.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
XCODEGEN_VERSION="2.38.0"
|
||||||
|
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
|
||||||
|
|
||||||
|
echo "==> Ensuring dmgbuild"
|
||||||
|
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
|
||||||
|
|
||||||
|
if command -v xcodegen >/dev/null 2>&1; then
|
||||||
|
echo "==> xcodegen already on PATH: $(xcodegen --version)"
|
||||||
|
else
|
||||||
|
echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)"
|
||||||
|
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||||
|
ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip"
|
||||||
|
curl -fL --retry 3 \
|
||||||
|
"https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \
|
||||||
|
-o "$ZIP"
|
||||||
|
rm -rf "$INSTALL_ROOT"
|
||||||
|
mkdir -p "$INSTALL_ROOT"
|
||||||
|
# Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets};
|
||||||
|
# XcodeGen resolves its presets relative to the binary, so keep the tree.
|
||||||
|
unzip -q "$ZIP" -d "$INSTALL_ROOT"
|
||||||
|
BIN_DIR="$INSTALL_ROOT/xcodegen/bin"
|
||||||
|
chmod +x "$BIN_DIR/xcodegen"
|
||||||
|
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||||
|
echo "$BIN_DIR" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
PATH="$BIN_DIR:$PATH"
|
||||||
|
echo "==> Installed: $("$BIN_DIR/xcodegen" --version)"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user