Compare commits

...
Author SHA1 Message Date
gronod 2a8ad4d5d4 fix(ci): cap pip below 26.1 in the Python 3.9 dmgbuild venv
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Successful in 4m5s
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 3m29s
Unbounded pip upgrade installed 26.2.1, which uses
dataclass(slots=True) and crashes on Monterey 3.9. Recreate
the venv if pip is already broken, then pin pip>=24.3,<26.1
before force-installing dmgbuild 1.6.7.
2026-09-14 10:34:16 +00:00
gronod c2ac9341c8 fix(ci): force dmgbuild 1.6.7 into the package venv
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m6s
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Failing after 35s
Python 3.9 hides 1.6.6+ (Requires-Python >=3.10), so
dmgbuild>=1.6.5 left a cached 1.6.5 install. The wheels are
py3-none-any; install 1.6.7 with PIP_IGNORE_REQUIRES_PYTHON,
force-reinstall ds_store>=1.3.3 and mac_alias>=2.2.3, and fail
if the venv is still older than 1.6.7.
2026-09-14 10:29:33 +00:00
gronod 1c310706e4 fix(ci): isolate dmgbuild in a venv, skip it on the test job
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m50s
macOS CI / build-and-test (push) Successful in 13s
macOS CI / package (push) Successful in 4m12s
ensure-host-tools.sh always bootstraps xcodegen. dmgbuild now
lives in build/.venv-dmgbuild and is only installed when
INSTALL_DMGBUILD=1 (package job). The test job no longer
pip-installs dmgbuild, which is what failed on Python 3.9.

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

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

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

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

Refs #165
2026-09-14 00:34:35 +01:00
gronod 407136c5a2 fix(ui): calibrate dashboard no longer overflows window; Escape exits (#163)
macOS CI / build-and-test (pull_request) Successful in 6m4s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Canceled after 1m44s
macOS CI / package (push) Skipped
2026-09-13 22:16:01 +01:00
gronod 83b5f6bf75 Merge pull request 'ci(macos): skip tests on tags containing "prerelease"' (#162) from chore/ci-prerelease-skip into develop
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16s
Reviewed-on: #162
2026-09-13 21:04:38 +01:00
gronod 16df18127c ci(macos): skip tests on tags containing "prerelease"
macOS CI / build-and-test (pull_request) Successful in 37m58s
macOS CI / package (pull_request) Skipped
2026-09-13 20:41:32 +01:00
gronod 78baa49e61 Merge pull request 'fix(gamut): Close button; skip SCNView without Metal (#147)' (#161) from fix/147-gamut-close-no-gpu into milestone/m10-studio
macOS CI / build-and-test (pull_request) Successful in 32m11s
macOS CI / package (pull_request) Skipped
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 13m23s
2026-09-13 18:49:35 +01:00
gronod 04a563c54a test(print): scroll gallery until btnPrintPage is hittable (#132) 2026-09-13 18:24:43 +01:00
gronod 2cbd43470d fix(gamut): Close button; skip SCNView without Metal (#147)
Refs #147 #146
2026-09-13 18:24:43 +01:00
gronod 14f9e6f78d Merge pull request 'fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)' (#160) from fix/147-gamut-toggle-disabled into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 5m30s
2026-09-13 17:05:05 +01:00
gronod f16825be72 fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)
Refs #147 #146
2026-09-13 17:03:56 +01:00
gronod 9fcd21dc2d Merge pull request 'test(m10): combine gamut layer toggle AX; harden gallery wait on macOS 12' (#159) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 30m21s
2026-09-13 15:53:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f5c57649e1 test(m10): combine gamut layer toggle AX; harden gallery wait on macOS 12
Run 33803 still reported the layer toggles disabled after a real
waitUntilEnabled: on macOS 12 the identifier lands on the Toggle's
container element, which never reports isEnabled — combine the
Toggle's children so the a11y leaf is the checkbox itself (checkbox
style unchanged). testPrinttargFixtureGalleryAndStubbedPrint flaked
once on the same runner: wait for btnCreateLayout enabled before
clicking and retry once when the gallery never materialises.

Refs #147 #149 #146

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 15:52:53 +01:00
gronod e967502ea7 Merge pull request 'test(m10): wait for gamut layer enable; drive New via File menu on macOS 12' (#158) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 32m15s
macOS CI / package (pull_request) Skipped
2026-09-13 14:59:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> eb3ae08cf9 test(m10): wait for gamut layer enable; drive New via File menu on macOS 12
Actions run 33718 (Xcode 14.2, macOS 12) failed four UI tests that
pass locally. Gamut layer toggles render as disabled placeholders
until the async layer load lands, so waitFor(id) won the race on the
placeholder — added waitUntilEnabled and gate on isEnabled. The
project New test's ⌘N may not reach the CommandGroup and alert
buttons often lack identifiers on macOS 12 — drive New through the
File menu "New Project" item and confirm the alert by button title
with id fallback.

Refs #147 #149 #146

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 14:59:11 +01:00
gronod 3d8e70be62 Merge pull request 'fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)' (#157) from fix/146-viewbuilder-ten-child into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 29m46s
macOS CI / package (pull_request) Skipped
2026-09-13 13:35:36 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f43c275152 fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)
Actions run 33666 (Xcode 14.2, macOS 12) failed to compile: the
sidebar VStack had 14 children and SaveMediaRecipeDialog had 13,
over the Swift 5.7 ViewBuilder 10-child cap. Extracted
layout-transparent Group subviews (header/presetBlock/mediaBlock/
studioButtons/stepperAndProject; fields/readOnlyRows) — no id,
order, or layout change.

Refs #146

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 13:33:42 +01:00
gronod 48d2816c73 Merge pull request 'docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12' (#155) from chore/m10-docs-hygiene into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 1m23s
macOS CI / package (pull_request) Skipped
2026-09-13 12:24:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b494b4fffd docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12
Refs #148

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 12:23:43 +01:00
gronod c39afe0dfd Merge pull request 'feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)' (#154) from feat/149-project-file into milestone/m10-studio 2026-09-13 11:29:58 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 1d78957d7a feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 11:28:40 +01:00
gronod 99c0d7e83d Merge pull request 'feat(gamut): second-profile compare and click-inspect (#147)' (#153) from feat/147-gamut-compare into milestone/m10-studio 2026-09-13 10:24:24 +01:00
39 changed files with 3324 additions and 195 deletions
+16 -4
View File
@@ -30,19 +30,26 @@ jobs:
fi
echo "$line"
# Tag pushes whose name contains "prerelease" skip the test build and both
# test legs: they exist to package a build already validated elsewhere.
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead.
# prebuilt release instead. dmgbuild is not installed here — the
# test job does not package (#95).
- name: Ensure host tools
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: xcodegen generate --spec project.yml
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
xcodebuild build-for-testing \
-scheme ICCery \
@@ -60,6 +67,7 @@ jobs:
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
@@ -68,6 +76,7 @@ jobs:
done
- name: Test unit (ICCeryCoreTests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
@@ -90,6 +99,7 @@ jobs:
# tag packaging can proceed. Real XCTest assertion failures
# still fail the job.
- name: Test UI (ICCeryUITests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
@@ -154,7 +164,7 @@ jobs:
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Prepare Node CA bundle (failure path)
if: failure()
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
@@ -166,7 +176,7 @@ jobs:
fi
- name: Upload UI test xcresult
if: failure()
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
@@ -184,8 +194,10 @@ jobs:
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
# the test job never pip-installs it (#95).
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
@@ -0,0 +1,218 @@
import Foundation
/// The `.icceryproj` file a JSON **index** over a basename + working
/// directory + optional media recipe/preset binding + last verification
/// snapshot (issue #149, docs/06 §Project file).
///
/// The project is never a second source of truth: artefact gating stays
/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to
/// persist the live session. snake_case keys match the v1 schema;
/// `schema_version != 1` is a hard decode error v2 fields are never
/// partially decoded.
public struct ICCeryProject: Codable, Equatable, Sendable {
/// The only schema version this build reads and writes.
public static let currentSchemaVersion = 1
public var schemaVersion: Int
public var name: String
public var notes: String
/// Run name without extension never `CAL_`-persisted (#60/R11).
public var basename: String
/// Absolute working directory holding the artefacts (#59).
public var cwd: String
/// May differ from `basename` after a `.ti3` import (#94).
public var profileBasename: String?
/// CUPS queue id, when a printer was selected.
public var printerID: String?
public var printerDisplayName: String?
/// `MediaRecipe.id` optional; ignored when the library file is
/// absent or the id is unknown (soft-dependency, #146).
public var mediaRecipeID: String?
public var presetID: String?
/// Absolute `.cal` path stored verbatim; `nil` = none.
public var calibrationURL: String?
public var lastVerification: VerificationSnapshot?
public var updated: Date
public init(
schemaVersion: Int = ICCeryProject.currentSchemaVersion,
name: String = "",
notes: String = "",
basename: String,
cwd: String,
profileBasename: String? = nil,
printerID: String? = nil,
printerDisplayName: String? = nil,
mediaRecipeID: String? = nil,
presetID: String? = nil,
calibrationURL: String? = nil,
lastVerification: VerificationSnapshot? = nil,
updated: Date = Date()
) {
self.schemaVersion = schemaVersion
self.name = name
self.notes = notes
self.basename = basename
self.cwd = cwd
self.profileBasename = profileBasename
self.printerID = printerID
self.printerDisplayName = printerDisplayName
self.mediaRecipeID = mediaRecipeID
self.presetID = presetID
self.calibrationURL = calibrationURL
self.lastVerification = lastVerification
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case name, notes, basename, cwd
case profileBasename = "profile_basename"
case printerID = "printer_id"
case printerDisplayName = "printer_display_name"
case mediaRecipeID = "media_recipe_id"
case presetID = "preset_id"
case calibrationURL = "calibration_url"
case lastVerification = "last_verification"
case updated
}
/// Strict decode: `schema_version` is required and must equal 1
/// anything else throws before a single v2 field is read. Required
/// strings (`basename`, `cwd`) must be present; optionals default.
/// Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let version = try c.decode(Int.self, forKey: .schemaVersion)
guard version == ICCeryProject.currentSchemaVersion else {
throw ValidationError.unsupportedSchema(version)
}
schemaVersion = version
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
basename = try c.decode(String.self, forKey: .basename)
cwd = try c.decode(String.self, forKey: .cwd)
profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename)
printerID = try c.decodeIfPresent(String.self, forKey: .printerID)
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName)
mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID)
presetID = try c.decodeIfPresent(String.self, forKey: .presetID)
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification)
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
}
public enum ValidationError: LocalizedError, Equatable {
case unsupportedSchema(Int)
case emptyBasename
case invalidBasename(String)
case emptyCwd
case unsafeCwd(String)
case invalidCalibrationURL(String)
public var errorDescription: String? {
switch self {
case .unsupportedSchema:
return "This project file is not schema 1."
case .emptyBasename:
return "A project needs a target basename."
case .invalidBasename(let v):
return "Illegal project basename \"\(v)\"."
case .emptyCwd:
return "A project needs a working folder."
case .unsafeCwd(let v):
return "cwd must be an absolute path, got \"\(v)\"."
case .invalidCalibrationURL(let v):
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
}
}
}
/// Validates the index fields. Empty basename/cwd refuse (#59/#60);
/// basename still rejects `/`, `\`, `..`; cwd must be absolute. An
/// empty `name` falls back to the basename.
@discardableResult
public func validated() throws -> ICCeryProject {
var p = self
p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines)
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.basename.isEmpty else { throw ValidationError.emptyBasename }
guard PathSecurity.isValidBasename(p.basename) else {
throw ValidationError.invalidBasename(p.basename)
}
let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd }
guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else {
throw ValidationError.unsafeCwd(p.cwd)
}
p.cwd = trimmedCwd
if p.name.isEmpty { p.name = p.basename }
if let cal = p.calibrationURL, !cal.isEmpty {
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
throw ValidationError.invalidCalibrationURL(cal)
}
}
return p
}
/// Reads a `.icceryproj` file. Throws `unsupportedSchema` for
/// `schema_version != 1` and the decode/validation error otherwise;
/// callers must leave live state untouched on failure.
public static func load(from url: URL) throws -> ICCeryProject {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(ICCeryProject.self, from: data).validated()
}
/// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213).
/// Validation runs first a refused project never touches disk.
public func save(to url: URL) throws {
let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601)
try AtomicFileWriter.write(try encoder.encode(validated()), to: url)
}
}
/// The last `VerificationRecord` frozen into the project file notes
/// only, never gating truth.
public struct VerificationSnapshot: Codable, Equatable, Sendable {
public var date: Date
public var avgDE00: Double
public var maxDE00: Double
/// `VerificationStatus.rawValue`.
public var status: String
public var profileFilename: String
public init(
date: Date,
avgDE00: Double,
maxDE00: Double,
status: String,
profileFilename: String
) {
self.date = date
self.avgDE00 = avgDE00
self.maxDE00 = maxDE00
self.status = status
self.profileFilename = profileFilename
}
public init(record: VerificationRecord) {
self.init(
date: record.timestamp,
avgDE00: record.avgDE,
maxDE00: record.maxDE,
status: record.status.rawValue,
profileFilename: record.profileName
)
}
enum CodingKeys: String, CodingKey {
case date
case avgDE00 = "avg_de00"
case maxDE00 = "max_de00"
case status
case profileFilename = "profile_filename"
}
}
@@ -0,0 +1,95 @@
import Foundation
/// `Save Report` a one-page UTF-8 Markdown summary written next to
/// the artefacts as `{basename}-report.md` (issue #149). Generated
/// output, safe to overwrite. Filenames only never absolute paths
/// and no HTML.
public enum ProjectReport {
/// `{cwd}/{basename}-report.md`.
public static func url(for project: ICCeryProject) -> URL {
URL(fileURLWithPath: project.cwd, isDirectory: true)
.appendingPathComponent("\(project.basename)-report.md")
}
/// Renders the report. `artefacts` is the live probe result so the
/// checklist shows what is actually on disk, not what the project
/// JSON claims (R18).
public static func markdown(
project: ICCeryProject,
recipeName: String?,
artefacts: StageArtefacts
) -> String {
var lines: [String] = []
lines.append("# \(project.name)")
lines.append("")
if let printer = project.printerDisplayName ?? project.printerID,
!printer.isEmpty {
lines.append("- **Printer:** \(printer)")
}
if let recipeName, !recipeName.isEmpty {
lines.append("- **Media recipe:** \(recipeName)")
}
if let preset = project.presetID, !preset.isEmpty {
lines.append("- **Preset:** \(preset)")
}
lines.append("")
lines.append("## Artefacts")
lines.append("")
lines.append("| File | Status |")
lines.append("|------|--------|")
lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete))
lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete))
lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete))
let profileName = artefacts.profilePath?.lastPathComponent
?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)"
lines.append(row(profileName, exists: artefacts.stage4Complete))
if let gam = artefacts.gamPath {
lines.append(row(gam.lastPathComponent, exists: true))
}
lines.append("")
if let verification = project.lastVerification {
lines.append("## Last verification")
lines.append("")
lines.append(
"- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))"
+ " (\(verification.status))")
lines.append("- Profile: \(verification.profileFilename)")
lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))")
lines.append("")
}
if !project.notes.isEmpty {
lines.append("## Notes")
lines.append("")
lines.append(project.notes)
lines.append("")
}
return lines.joined(separator: "\n")
}
/// Writes `{cwd}/{basename}-report.md` atomically, overwriting an
/// existing generated report.
@discardableResult
public static func write(
project: ICCeryProject,
recipeName: String?,
artefacts: StageArtefacts
) throws -> URL {
let destination = url(for: project)
try AtomicFileWriter.write(
markdown(project: project, recipeName: recipeName, artefacts: artefacts),
to: destination)
return destination
}
private static func row(_ filename: String, exists: Bool) -> String {
"| \(filename) | \(exists ? "exists" : "missing") |"
}
private static func f(_ value: Double) -> String {
String(format: "%.2f", value)
}
}
@@ -0,0 +1,162 @@
import Foundation
/// One row in `recent_projects.json` bookmark `Data` *and* the plain
/// absolute path so the entry still resolves when the bookmark can't.
public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable {
public var name: String
/// Absolute path to the `.icceryproj` file.
public var path: String
/// File bookmark; optional path is the fallback.
public var bookmark: Data?
public var updated: Date
public var id: String { path }
/// Stable digest of `path` for `projectRecent-{hash}` menu ids
/// FNV-1a 64, stable across launches unlike `hashValue`.
public var bookmarkHash: String {
var hash: UInt64 = 0xcbf29ce484222325
for byte in path.utf8 {
hash ^= UInt64(byte)
hash &*= 0x100000001b3
}
return String(hash, radix: 16)
}
public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) {
self.name = name
self.path = path
self.bookmark = bookmark
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case name, path, bookmark, updated
}
}
/// Recent-projects list (issue #149). Lives in
/// `AppPaths.appDataDir/recent_projects.json` app data, never inside
/// the project file (R12).
///
/// Cap 20, newest first, deduplicated by path. Entries whose file is
/// gone are dropped by `pruneMissing()` (called when the Open Recent
/// submenu builds). A corrupt file throws on load and is never
/// overwritten the view model shows an empty list and keeps the
/// bytes (`.throwCorrupt`, R12).
public actor RecentProjectsStore {
/// Default cap.
public static let defaultCapacity = 20
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var entries: [RecentProjectEntry] = []
/// Explicit load flag an empty file is still "loaded".
private var loaded = false
private let capacity: Int
private let fileStore: JSONFileStore<[RecentProjectEntry]>
private let fileManager: FileManager
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"),
capacity: Int = defaultCapacity,
fileManager: FileManager = .default
) {
self.url = url
self.capacity = capacity
self.fileManager = fileManager
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads entries from disk. Returns the existing cache if already
/// loaded. Throws when the file exists but cannot be parsed; the
/// existing file is never overwritten in that case.
public func load() throws -> [RecentProjectEntry] {
guard !loaded else { return entries }
guard fileManager.fileExists(atPath: url.path) else {
loaded = true
return []
}
entries = try fileStore.load()
loaded = true
return entries
}
/// Returns all cached entries, newest first.
public func all() -> [RecentProjectEntry] {
entries
}
/// Pushes a project to the front, deduplicating by path and
/// trimming to `capacity`. Writes atomically.
///
/// Loads the existing list first and propagates any load error so
/// an unparseable file is never overwritten.
@discardableResult
public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] {
try load()
let bookmark = try? fileURL.bookmarkData()
var updated = entries.filter { $0.path != fileURL.path }
updated.insert(
RecentProjectEntry(
name: name,
path: fileURL.path,
bookmark: bookmark,
updated: Date()),
at: 0)
if updated.count > capacity {
updated = Array(updated.prefix(capacity))
}
try fileStore.save(updated)
entries = updated
return updated
}
/// Removes an entry by path and writes atomically. Returns false
/// when no entry with that path exists.
@discardableResult
public func remove(path: String) throws -> Bool {
try load()
let before = entries.count
let updated = entries.filter { $0.path != path }
guard updated.count != before else { return false }
try fileStore.save(updated)
entries = updated
return true
}
/// `Clear Menu` wipes the recents file only; `.icceryproj` files
/// are never deleted (R12).
public func clear() throws {
try load()
try fileStore.save([])
entries = []
}
/// Drops entries whose file is gone and rewrites the store.
/// Called when the Open Recent submenu builds missing files are
/// dropped there, not at launch (issue #149).
@discardableResult
public func pruneMissing() throws -> [RecentProjectEntry] {
try load()
let kept = entries.filter {
fileManager.fileExists(atPath: $0.path)
}
if kept.count != entries.count {
try fileStore.save(kept)
entries = kept
}
return kept
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|---|---|
| Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` |
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
+15 -1
View File
@@ -14,6 +14,7 @@ struct AppEnvironment: Sendable {
let cupsService: CupsService
let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore
let recentProjectsStore: RecentProjectsStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -47,7 +48,8 @@ struct AppEnvironment: Sendable {
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore()
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
)
}
}
@@ -89,6 +91,18 @@ enum UITestHooks {
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
/// TIFF sample picker result (gamut sheet, #147). Unset cancel.
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
/// `selectProjectFile` result (`.icceryproj` open, #149). Unset cancel.
static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") }
/// `selectProjectSavePath` result (`.icceryproj` save-as, #149).
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
/// Relocate-folder result when a project's `cwd` is missing (#149).
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
/// host (#147). Set per-test only never in a default launch env,
/// or CI's future GPU run would skip SceneKit too.
static var skipSceneKit: Bool {
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
}
// MARK: - Print panel / CUPS stubs (issue 13/17)
+121 -88
View File
@@ -7,102 +7,135 @@ struct CalibrationView: View {
@ObservedObject var wizard: WizardViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Calibrate Printer")
.font(.title2.bold())
.padding(.horizontal, 16)
.padding(.top, 16)
Form {
Section("Wedge Settings") {
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
HStack {
Text("Steps per channel")
Spacer()
TextField("", value: $model.steps, format: .number)
.frame(width: 60)
.accessibilityIdentifier("calSteps")
}
HStack {
Text("White patches")
Spacer()
TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60)
}
if model.colourSpace == .cmyk {
HStack {
Text("Ink-limit exploration")
Spacer()
TextField("", text: $model.inkLimit)
.frame(width: 60)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
}
Section("Workflow") {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.calibrationLog.isEmpty {
Section("Log") {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.calibrationLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
}
}
}
.frame(minHeight: 80, maxHeight: 120)
}
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Calibrate Printer")
.font(.title2.bold())
.foregroundStyle(Theme.text)
wedgeSection
workflowSection
if !model.calibrationLog.isEmpty { logSection }
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.background)
Divider().overlay(Theme.border)
HStack {
Spacer()
Button("Return to Profiling") { model.returnToProfiling() }
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCalReturn")
}
.padding(16)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("stage-cal")
}
// MARK: - Wedge settings
private var wedgeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
.pickerStyle(.segmented)
.frame(maxWidth: 220)
HStack(spacing: 12) {
Text("Steps per channel")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.steps, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calSteps")
}
HStack(spacing: 12) {
Text("White patches")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.whitePatches, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
}
if model.colourSpace == .cmyk {
HStack(spacing: 12) {
Text("Ink-limit exploration")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", text: $model.inkLimit)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("calNeutralEmphasis")
}
}
// MARK: - Workflow
private var workflowSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Log
private var logSection: some View {
ProcessLogView(
lines: model.calibrationLog,
minHeight: 80,
maxHeight: 120,
containerId: "calLogContainer",
logId: "calLog"
)
}
}
+19
View File
@@ -95,6 +95,23 @@ final class FileDialogService {
message: "Export this preset as JSON")
}
/// `selectProjectFile` open `.icceryproj` only (issue #149).
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["icceryproj"], startingAt: start,
message: "Open ICCery Project",
title: "Open ICCery Project",
allowsOtherFileTypes: false)
}
/// `selectProjectSavePath` save `.icceryproj`, suggested name
/// `{basename}.icceryproj` in the working folder (issue #149).
func selectProjectSavePath(
basename: String, startingAt start: URL? = nil
) -> URL? {
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
startingAt: start, message: "Save ICCery Project")
}
// MARK: - Internals (private not a shared public picker API)
private func save(
@@ -116,6 +133,7 @@ final class FileDialogService {
extensions: [String],
startingAt start: URL?,
message: String?,
title: String? = nil,
allowsOtherFileTypes: Bool = true
) -> URL? {
let panel = NSOpenPanel()
@@ -126,6 +144,7 @@ final class FileDialogService {
panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.directoryURL = start
if let message { panel.message = message }
if let title { panel.title = title }
return run(panel)
}
+26 -2
View File
@@ -74,6 +74,7 @@ struct GamutView: View {
@StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool
@Environment(\.dismiss) private var dismiss
@Binding var showingAllHelp: Bool
init(
@@ -94,6 +95,8 @@ struct GamutView: View {
Divider().overlay(Theme.border)
statusLine
inspectPanel
Divider().overlay(Theme.border)
footer
}
.frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
@@ -142,8 +145,12 @@ struct GamutView: View {
Text(layer?.displayName ?? fallback)
}
.toggleStyle(.checkbox)
.disabled(layer == nil || viewModel.viewerUnavailable)
.disabled(layer == nil)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
// macOS 12 puts the identifier on the Toggle's container, an
// element that never reports isEnabled combine so the a11y
// leaf is the checkbox itself.
.accessibilityElement(children: .combine)
.accessibilityIdentifier("gamutLayer-\(id)")
}
@@ -318,6 +325,21 @@ struct GamutView: View {
}
}
// MARK: - Footer
/// Always-visible Close (#147) the fallback banner keeps it
/// reachable and Escape works via `.cancelAction` without SceneKit.
private var footer: some View {
HStack {
Spacer()
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCloseGamut")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
// MARK: - TIFF sample sheet
private var tiffPreviewSheet: some View {
@@ -377,7 +399,9 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.installKeyMonitor()
context.coordinator.installClickGesture()
// No GPU the docs/18 fallback; never respawn the view in a loop.
// Safety net only the primary no-Metal check is
// `GamutSceneAvailability.isAvailable`, evaluated before this
// view is mounted. Never respawn the view in a loop.
if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() }
}
+15
View File
@@ -1,8 +1,21 @@
import Combine
import Foundation
import ICCeryCore
import Metal
import simd
/// Whether the SceneKit gamut scene can render on this host (#147).
///
/// Checked **before** `GamutSceneView` is mounted constructing an
/// `SCNView` on a Metal-less machine can wedge the main thread, which
/// also stalls app quit behind the open sheet.
enum GamutSceneAvailability {
static var isAvailable: Bool {
if UITestHooks.skipSceneKit { return false }
return MTLCreateSystemDefaultDevice() != nil
}
}
/// View model for the native SceneKit gamut viewer (issues #28, #147).
///
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
@@ -73,6 +86,8 @@ final class GamutViewModel: ObservableObject {
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
self.environment = environment
self.profileGamURL = profileGamURL
// Never let the view mount an SCNView without Metal (#147).
viewerUnavailable = !GamutSceneAvailability.isAvailable
loadTask = Task { await load() }
}
+7 -2
View File
@@ -25,8 +25,13 @@ struct ICCeryApp: App {
.preferredColorScheme(.dark)
}
.commands {
// Single-window app: no File > New window.
CommandGroup(replacing: .newItem) {}
// Single-window app: the File menu carries the project
// commands (issue #149). Replacing `.newItem` keeps
// SwiftUI's empty default New from stacking (R19 the
// group is filled, so no second New appears).
CommandGroup(replacing: .newItem) {
ProjectCommands(workflow: workflow)
}
}
}
}
+17 -3
View File
@@ -46,9 +46,11 @@ struct SaveMediaRecipeDialog: View {
|| media.captureColourSpaceMismatch
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
// children (#146); Group blocks are layout-transparent, so field
// order and every docs/21 id are unchanged.
private var fields: some View {
Group {
TextField("Name", text: $media.saveMediaName)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaName")
@@ -61,7 +63,11 @@ struct SaveMediaRecipeDialog: View {
TextField("Ink set", text: $media.saveMediaInk)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaInk")
}
}
private var readOnlyRows: some View {
Group {
captureRow("Printer", value: printerCaption,
identifier: "saveMediaPrinter")
captureRow("Preset",
@@ -74,6 +80,14 @@ struct SaveMediaRecipeDialog: View {
value: workflow.profile.calibrationFile.isEmpty
? "None" : workflow.profile.calibrationFile,
identifier: "saveMediaCal")
}
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
fields
readOnlyRows
Toggle("Apply calibration to profile",
isOn: $media.saveMediaApplyCal)
.disabled(!media.calApplyable)
+689
View File
@@ -0,0 +1,689 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Project file session (issue #149): `.icceryproj` open/save/new/close,
/// recents, the dirty flag, and the window title.
///
/// The project is an **index** disk artefacts still own the stepper
/// (R18). Open writes `WizardState` through the normal setters and ends
/// in the same probe `windowDidBecomeKey` uses; it never auto-runs
/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk
/// does not back.
@MainActor
final class ProjectSession: ObservableObject {
/// What to do once the dirty alert resolves.
enum PendingAction {
case new
case open(URL)
case close
}
/// A live session snapshot, compared against the bound project for
/// the dirty flag (title ``, `btnProjectSave`).
private struct Snapshot: Equatable {
var basename: String
var cwd: String
var printerID: String?
var presetID: String?
var mediaRecipeID: String?
var calibrationURL: String?
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
private let recentsStore: RecentProjectsStore
private var cancellables = Set<AnyCancellable>()
/// Bound project file location; `nil` = no project.
@Published var projectURL: URL?
/// Last saved/opened project payload.
@Published var project: ICCeryProject?
/// Open Recent entries, newest first, missing files pruned.
@Published var recents: [RecentProjectEntry] = []
/// JSON claimed a finished profile but disk stops earlier
/// `projectChipStale` + info banner (R18).
@Published var diskBehindNotes = false
/// `ICCery` / `ICCery {name}` / `ICCery {name} `.
@Published var windowTitle = "ICCery"
/// Live session fields differ from the bound project computed
/// fresh so decision paths (`requestNew`/`requestClose`) never see
/// a stale willSet value.
var isDirty: Bool {
guard let project else { return false }
return liveSnapshot() != snapshot(of: project)
}
// Flow state for RootView.
@Published var showingNewAlert = false
@Published var showingDirtyAlert = false
@Published var showingRelocateSheet = false
private var pendingAction: PendingAction?
/// Loaded project whose `cwd` is missing relocate sheet payload.
@Published private(set) var pendingRelocate:
(project: ICCeryProject, url: URL)?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.recentsStore = environment.recentProjectsStore
refreshRecents()
// Dirty / title derive from live fields observe each source;
// child VMs are not tracked through the parent's
// objectWillChange (R22-style weak sinks). `@Published` fires on
// willSet, so the recompute is deferred one main turn to read
// post-set values.
let wizard = workflow.wizard
let printSession = workflow.print
let media = workflow.media
let profile = workflow.profile
wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
recomputeDerived()
}
deinit { cancellables.removeAll() }
// MARK: - Derived
var isBound: Bool { project != nil }
/// A chartread / spotread / colprof child is live project changes
/// wait for the instrument run to finish.
var childSessionLive: Bool {
workflow.measurement.isChartreadRunning
|| workflow.spotRead.isRunning
|| workflow.profile.isColprofRunning
}
/// Basename eligible for persistence. A live `CAL_` stem resolves
/// only through the persisted original never by stripping, so a
/// bare `CAL_` (Force-Quit anomaly, empty persisted original) is
/// refused rather than trusted (R11).
var resolvedBasename: String? {
let live = workflow.wizard.basename
guard CalibrationIdentity.isCalibration(live) else {
return live.isEmpty ? nil : live
}
let original = workflow.wizard.calibrationOriginalBasename
return original.isEmpty ? nil : original
}
/// S / `btnProjectSave` enable rule. A `CAL_` live basename stays
/// enabled so the refusal can show its banner.
var canSave: Bool {
isBound && !workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectSaveAs` no binding required.
var canSaveAs: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectReport` needs a real basename and cwd on disk.
var canReport: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
private func liveSnapshot() -> Snapshot {
Snapshot(
basename: resolvedBasename ?? "",
cwd: workflow.wizard.workingDirectory?.path ?? "",
printerID: workflow.print.selectedPrinter.isEmpty
? nil : workflow.print.selectedPrinter,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile)
}
private func snapshot(of project: ICCeryProject) -> Snapshot {
Snapshot(
basename: project.basename,
cwd: project.cwd,
printerID: project.printerID,
presetID: project.presetID,
mediaRecipeID: project.mediaRecipeID,
calibrationURL: project.calibrationURL)
}
/// Recomputes the window title from live fields.
func recomputeDerived() {
if let project {
windowTitle = "ICCery — \(project.name)" + (isDirty ? "" : "")
} else {
windowTitle = "ICCery"
}
}
/// Defer the recompute one main turn the Combine sinks fire on
/// willSet, before the changed property holds its new value.
private func scheduleRecompute() {
Task { @MainActor [weak self] in self?.recomputeDerived() }
}
// MARK: - New
/// N / `menuProjectNew`. Dirty sessions detour through the dirty
/// alert first; a live child gets a banner instead of the alert.
func requestNew() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before starting a project.",
kind: .warning)
return
}
if isDirty {
pendingAction = .new
showingDirtyAlert = true
return
}
showingNewAlert = true
}
/// `btnProjectNewConfirm` unbinds and clears the basename. Empty
/// is the legal "no target yet" state (#60); artefacts and
/// `media_library.json` are never deleted (R18).
func confirmNew() {
showingNewAlert = false
projectURL = nil
project = nil
pendingRelocate = nil
diskBehindNotes = false
workflow.wizard.basename = ""
workflow.targetBasename = ""
workflow.media.selectedRecipeID = "none"
workflow.wizard.sessionMode = .profile
// Re-probe an empty basename locks stages 25.
workflow.wizard.windowDidBecomeKey()
recomputeDerived()
}
// MARK: - Open
/// O / `btnProjectOpen` `selectProjectFile` (`.icceryproj`
/// only). Cancel is a no-op.
func requestOpen() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectOpenURL
: fileDialogs.selectProjectFile()
guard let url else { return }
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Open from the recents submenu same path, no open panel. A
/// missing file is dropped with a banner; no relocate is offered.
func openRecent(_ entry: RecentProjectEntry) {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = URL(fileURLWithPath: entry.path)
guard FileManager.default.fileExists(atPath: url.path) else {
Task { try? await recentsStore.remove(path: entry.path) }
refreshRecents()
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
return
}
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Shared open path validates, routes to relocate when `cwd` is
/// gone, then applies. Failures leave live state untouched.
func open(_ url: URL) {
guard let loaded = loadProject(url) else { return }
apply(loaded, from: url)
}
/// Awaitable open for tests identical to `open` but completes
/// after apply finishes.
func openAsync(_ url: URL) async {
guard let loaded = loadProject(url) else { return }
await applyAsync(loaded, from: url)
}
/// Decode + validate + cwd-existence check. Returns the project
/// ready to apply, or nil after presenting an error banner / the
/// relocate sheet.
private func loadProject(_ url: URL) -> ICCeryProject? {
let loaded: ICCeryProject
do {
loaded = try ICCeryProject.load(from: url)
} catch let error as ICCeryProject.ValidationError {
workflow.wizard.showNotice(
error.errorDescription ?? "Could not open project.",
kind: .error)
return nil
} catch {
workflow.wizard.showNotice(
"Could not open project: \(error.localizedDescription)",
kind: .error)
return nil
}
var isDir: ObjCBool = false
guard FileManager.default.fileExists(
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
else {
pendingRelocate = (loaded, url)
showingRelocateSheet = true
return nil
}
return loaded
}
/// Sync entry the apply half runs in a Task so `media.apply`
/// (async) can run inside it.
private func apply(_ project: ICCeryProject, from url: URL) {
Task { @MainActor [weak self] in
await self?.applyAsync(project, from: url)
}
}
/// Apply order (issue #149):
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
/// 2. `mediaRecipeID` `MediaLibraryViewModel.apply`; else
/// `presetID` existing preset apply. Missing recipe is not
/// fatal open still succeeds.
/// 3. Re-probe via the existing window-focus path (#151).
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
workflow.wizard.setTarget(
basename: project.basename, workingDirectory: cwdURL)
workflow.targetBasename = project.basename
workflow.targetDirectory = cwdURL
workflow.wizard.printerName = project.printerDisplayName
?? project.printerID
workflow.wizard.profileBasename = project.profileBasename
workflow.wizard.sessionMode = .profile
if let queue = project.printerID,
workflow.print.printers.contains(where: { $0.name == queue }) {
workflow.print.selectedPrinter = queue
}
var appliedRecipe = false
if let recipeID = project.mediaRecipeID {
if workflow.media.recipes.isEmpty {
await workflow.media.reloadAsync()
}
if let recipe = workflow.media.recipes
.first(where: { $0.id == recipeID }) {
appliedRecipe = await workflow.media.apply(recipe)
}
}
if !appliedRecipe, let presetID = project.presetID,
let preset = workflow.presets
.first(where: { $0.id == presetID }) {
workflow.applyPreset(preset)
}
// Disk owns the stepper same probe window focus uses.
workflow.wizard.windowDidBecomeKey()
// JSON-vs-disk mismatch: notes say the profile is done but the
// artefacts stop earlier index drift, not gating truth.
let artefacts = workflow.wizard.artefacts
if project.lastVerification != nil && !artefacts.stage4Complete {
let already = diskBehindNotes && projectURL == url
diskBehindNotes = true
if !already {
let last = artefacts.stage3Complete ? ".ti3"
: artefacts.stage2Complete ? ".ti2"
: artefacts.stage1Complete ? ".ti1" : nil
let detail = last.map { "artefacts on disk stop at \($0)." }
?? "no artefacts found on disk."
workflow.wizard.showNotice(
"Project notes say profile done; \(detail)",
kind: .info)
}
} else {
diskBehindNotes = false
}
self.project = project
projectURL = url
pushRecent(url: url, name: project.name)
recomputeDerived()
}
// MARK: - Relocate
/// `btnProjectRelocate` pick a new cwd, rewrite the project file
/// atomically, then continue Apply.
func chooseRelocateFolder() {
guard let pending = pendingRelocate else { return }
let url = UITestHooks.isEnabled
? UITestHooks.projectRelocateURL
: fileDialogs.selectDirectory()
guard let url else { return }
var rewritten = pending.project
rewritten.cwd = url.path
rewritten.updated = Date()
do {
try rewritten.save(to: pending.url)
} catch {
workflow.wizard.showNotice(
"Could not update project: \(error.localizedDescription)",
kind: .error)
return
}
pendingRelocate = nil
showingRelocateSheet = false
apply(rewritten, from: pending.url)
}
/// `btnProjectRelocateCancel` aborts the open; live session
/// unchanged.
func cancelRelocate() {
pendingRelocate = nil
showingRelocateSheet = false
}
// MARK: - Save / Save As
/// S / `btnProjectSave` writes the live session into the bound
/// URL. A live `CAL_` stem refuses unless the persisted original
/// resolves; `CAL_` is never persisted (R11).
func saveProject() {
Task { @MainActor in _ = await saveProjectAsync() }
}
@discardableResult
func saveProjectAsync() async -> Bool {
guard let url = projectURL, project != nil else { return false }
guard let basename = resolvedBasename else {
refuseUnsavable()
return false
}
return await write(to: url, basename: basename)
}
/// S / `menuProjectSaveAs` always shows the save picker, then
/// binds the chosen URL and pushes recents.
func saveProjectAs() {
guard let basename = resolvedBasename else {
refuseUnsavable()
return
}
guard let cwd = workflow.wizard.workingDirectory else {
refuseUnsavable()
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectSaveURL
: fileDialogs.selectProjectSavePath(
basename: basename, startingAt: cwd)
guard let url else { return }
Task { @MainActor in _ = await write(to: url, basename: basename) }
}
private func refuseUnsavable() {
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
workflow.wizard.showNotice(
"Finish or exit calibration before saving a project.",
kind: .warning)
} else {
workflow.wizard.showNotice(
"Set a target basename and working folder before saving a project.",
kind: .warning)
}
}
/// Builds the project payload from live session fields and writes
/// it atomically. On success binds `url`, refreshes recents, and
/// clears the stale-chip flag. Failure error banner, bound URL
/// unchanged.
@discardableResult
private func write(to url: URL, basename: String) async -> Bool {
guard let cwd = workflow.wizard.workingDirectory,
!cwd.path.isEmpty else {
refuseUnsavable()
return false
}
let stem = workflow.wizard.profileBasename ?? basename
let snapshot = await lastVerificationSnapshot(stem: stem)
?? project?.lastVerification
let queue = workflow.print.selectedPrinter
let display = workflow.print.printers
.first { $0.name == queue }?.displayName
?? workflow.wizard.printerName
let existing = project
let name = (existing?.name.isEmpty == false)
? existing!.name : basename
let updated = ICCeryProject(
name: name,
notes: existing?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
try updated.save(to: url)
} catch {
workflow.wizard.showNotice(
"Could not save project: \(error.localizedDescription)",
kind: .error)
return false
}
project = updated
projectURL = url
diskBehindNotes = false
pushRecent(url: url, name: updated.name)
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
recomputeDerived()
return true
}
/// Last `VerificationHistoryStore` record for this profile stem,
/// if any notes only.
private func lastVerificationSnapshot(
stem: String
) async -> VerificationSnapshot? {
guard let records = try? await environment.historyStore.load()
else { return nil }
guard let record = records.last(where: {
$0.profileName == stem || $0.profileName == workflow.wizard.basename
}) else { return nil }
return VerificationSnapshot(record: record)
}
// MARK: - Close
/// `menuProjectClose` unbinds; live basename/cwd/artefacts stay.
func requestClose() {
guard isBound else { return }
if isDirty {
pendingAction = .close
showingDirtyAlert = true
return
}
closeProject()
}
func closeProject() {
projectURL = nil
project = nil
diskBehindNotes = false
recomputeDerived()
}
// MARK: - Dirty alert
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
/// to the pending action only when the write succeeded.
func resolveDirty(save: Bool) {
showingDirtyAlert = false
guard let action = pendingAction else { return }
if save {
Task { @MainActor [weak self] in
guard let self else { return }
if await self.saveProjectAsync() {
self.pendingAction = nil
self.proceed(with: action)
}
}
} else {
pendingAction = nil
proceed(with: action)
}
}
/// `btnProjectDirtyCancel` abandons the pending action.
func cancelDirty() {
pendingAction = nil
showingDirtyAlert = false
}
private func proceed(with action: PendingAction) {
switch action {
case .new:
confirmNew()
case .open(let url):
open(url)
case .close:
closeProject()
}
}
// MARK: - Report
/// `menuProjectReport` writes `{cwd}/{basename}-report.md`
/// atomically (generated; overwrites). No picker.
func saveReport() {
guard canReport, let cwd = workflow.wizard.workingDirectory else {
return
}
let basename = workflow.wizard.basename
Task { @MainActor [weak self] in
guard let self else { return }
let stem = self.workflow.wizard.profileBasename ?? basename
let snapshot = await self.lastVerificationSnapshot(stem: stem)
?? self.project?.lastVerification
let queue = self.workflow.print.selectedPrinter
let display = self.workflow.print.printers
.first { $0.name == queue }?.displayName
?? self.workflow.wizard.printerName
let recipeName = self.workflow.media.recipes
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
let payload = ICCeryProject(
name: self.project?.name.isEmpty == false
? self.project!.name : basename,
notes: self.project?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: self.workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
? nil : self.workflow.media.selectedRecipeID,
presetID: self.workflow.selectedPresetID == "none"
? nil : self.workflow.selectedPresetID,
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
? nil : self.workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
let written = try ProjectReport.write(
project: payload,
recipeName: recipeName,
artefacts: self.workflow.wizard.artefacts)
self.workflow.wizard.showNotice(
"Wrote \(written.lastPathComponent)")
} catch {
self.workflow.wizard.showNotice(
"Report failed: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Recents / reveal
/// Rebuilds the recents list; entries whose file is gone are
/// dropped here (submenu build), not at launch. Corrupt `[]`,
/// file kept (R12).
func refreshRecents() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.recents = try await self.recentsStore.pruneMissing()
} catch {
self.recents = []
}
}
}
private func pushRecent(url: URL, name: String) {
Task { @MainActor [weak self] in
guard let self else { return }
try? await self.recentsStore.add(url: url, name: name)
self.refreshRecents()
}
}
/// `menuProjectRecentsClear` wipes `recent_projects.json` only;
/// `.icceryproj` files are never deleted.
func clearRecents() {
Task { @MainActor [weak self] in
try? await self?.recentsStore.clear()
self?.recents = []
}
}
/// `btnProjectReveal` reveals the bound **project file** in
/// Finder, not the cwd.
func revealInFinder() {
guard let projectURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
import ICCeryCore
/// File-menu commands for the project file (issue #149). Content of the
/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` split out so the
/// app body stays small (type-checker budget). Menu ids are distinct
/// from the sidebar chip ids (`menuProject*` vs `btnProject*`).
struct ProjectCommands: View {
@ObservedObject private var project: ProjectSession
init(workflow: TargetWorkflowViewModel) {
self._project = ObservedObject(wrappedValue: workflow.project)
}
var body: some View {
Button("New Project") { project.requestNew() }
.keyboardShortcut("n")
.accessibilityIdentifier("menuProjectNew")
Button("Open Project…") { project.requestOpen() }
.keyboardShortcut("o")
.accessibilityIdentifier("menuProjectOpen")
Menu("Open Recent") {
ForEach(project.recents) { entry in
// Names render via Text only (#114); never the raw path.
Button(entry.name) { project.openRecent(entry) }
.accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)")
}
Divider()
Button("Clear Menu") { project.clearRecents() }
.accessibilityIdentifier("menuProjectRecentsClear")
}
.disabled(project.recents.isEmpty)
.accessibilityIdentifier("menuProjectRecents")
Divider()
Button("Save Project") { project.saveProject() }
.keyboardShortcut("s")
.disabled(!project.canSave)
.accessibilityIdentifier("menuProjectSave")
Button("Save Project As…") { project.saveProjectAs() }
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!project.canSaveAs)
.accessibilityIdentifier("menuProjectSaveAs")
Button("Save Report…") { project.saveReport() }
.disabled(!project.canReport)
.accessibilityIdentifier("menuProjectReport")
Divider()
Button("Close Project") { project.requestClose() }
.disabled(!project.isBound)
.accessibilityIdentifier("menuProjectClose")
}
}
/// Compact project footer at the bottom of the 270 pt sidebar (issue
/// #149) never a fourth row of large buttons (R10).
struct ProjectChip: View {
@ObservedObject var project: ProjectSession
@Binding var showingAllHelp: Bool
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let bound = project.project {
Text(bound.name)
.font(.callout)
.foregroundStyle(Theme.text)
.lineLimit(1)
.accessibilityIdentifier("projectChipName")
Text(URL(fileURLWithPath: bound.cwd).lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
// The raw path is a tooltip never the window title.
.help(bound.cwd)
.accessibilityIdentifier("projectChipPath")
if project.diskBehindNotes {
Text("Disk behind project notes")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("projectChipStale")
}
HStack(spacing: 8) {
Button("Show in Finder") { project.revealInFinder() }
.accessibilityIdentifier("btnProjectReveal")
Button("Save") { project.saveProject() }
.disabled(!project.canSave)
.accessibilityIdentifier("btnProjectSave")
Spacer()
}
.font(.caption)
.controlSize(.small)
} else {
HStack(spacing: 8) {
Text("No project")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("projectChipName")
Spacer()
Button("Open…") { project.requestOpen() }
.controlSize(.small)
.accessibilityIdentifier("btnProjectOpen")
}
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.fill(Theme.panel)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.stroke(Theme.border)
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("projectChip")
.helpOverlay(
"A project remembers printer, preset and folder. The stepper still follows files on disk.",
showing: $showingAllHelp)
}
}
/// `projectRelocateSheet` shown when an opened project's `cwd` no
/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the
/// project file atomically, then continues Apply; Cancel aborts the
/// open with live state untouched.
struct ProjectRelocateSheet: View {
@ObservedObject var project: ProjectSession
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Missing project folder")
.font(.title3)
.foregroundStyle(Theme.text)
Text(
"The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder."
)
.foregroundStyle(Theme.text)
HStack {
Spacer()
Button("Cancel") { project.cancelRelocate() }
.accessibilityIdentifier("btnProjectRelocateCancel")
Button("Choose Folder…") { project.chooseRelocateFolder() }
.accessibilityIdentifier("btnProjectRelocate")
}
}
.padding(20)
.frame(width: 420)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("projectRelocateSheet")
}
}
+34
View File
@@ -9,6 +9,7 @@ struct RootView: View {
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var project: ProjectSession
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
@@ -16,6 +17,7 @@ struct RootView: View {
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._project = ObservedObject(wrappedValue: workflow.project)
}
var body: some View {
@@ -37,6 +39,7 @@ struct RootView: View {
}
WizardStageContent(model: model, workflow: workflow)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
@@ -87,6 +90,37 @@ struct RootView: View {
profileGamURL: workflow.wizard.gamutProfileURL,
showingAllHelp: $showingAllHelp)
}
// Project file chrome (issue #149): window title, New confirm
// alert, dirty alert, relocate sheet. Panels never appear from
// a View UITestHooks inject fixture paths instead.
.onReceive(project.$windowTitle) { title in
for window in NSApp.windows where !(window is NSPanel) {
window.title = title
}
}
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
Button("Cancel", role: .cancel) {}
.accessibilityIdentifier("btnProjectNewCancel")
Button("Start") { project.confirmNew() }
.accessibilityIdentifier("btnProjectNewConfirm")
} message: {
Text("The working folder and targets on disk are not deleted.")
.accessibilityIdentifier("projectNewAlert")
}
.alert(
"Save the current project first?",
isPresented: $project.showingDirtyAlert
) {
Button("Save") { project.resolveDirty(save: true) }
.accessibilityIdentifier("btnProjectDirtySave")
Button("Don't Save") { project.resolveDirty(save: false) }
.accessibilityIdentifier("btnProjectDirtyDiscard")
Button("Cancel", role: .cancel) { project.cancelDirty() }
.accessibilityIdentifier("btnProjectDirtyCancel")
}
.sheet(isPresented: $project.showingRelocateSheet) {
ProjectRelocateSheet(project: project)
}
}
}
+25 -26
View File
@@ -69,22 +69,24 @@ struct SettingsView: View {
}
Section("Verification") {
HStack {
Text("Good ΔE ≤")
TextField(
"2.0",
value: $model.settings.deltaEGoodMax,
format: .number
)
.frame(width: 60)
Text("Warning ΔE ≤")
TextField(
"5.0",
value: $model.settings.deltaEWarningMax,
format: .number
)
.frame(width: 60)
}
TextField(
"Good ΔE ≤",
value: $model.settings.deltaEGoodMax,
format: .number
)
.accessibilityIdentifier("settingsDeltaEGood")
TextField(
"Warning ΔE ≤",
value: $model.settings.deltaEWarningMax,
format: .number
)
.accessibilityIdentifier("settingsDeltaEWarning")
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(model.validationErrors, id: \.self) { error in
Text(error)
.font(.caption)
@@ -93,16 +95,12 @@ struct SettingsView: View {
}
Section("Calibration") {
HStack {
Text("Stale after")
TextField(
"30",
value: $model.settings.calibrationStaleDays,
format: .number
)
.frame(width: 60)
Text("days")
}
TextField(
"Stale after (days)",
value: $model.settings.calibrationStaleDays,
format: .number
)
.accessibilityIdentifier("settingsCalStaleDays")
}
Section("Profile install") {
@@ -143,6 +141,7 @@ struct SettingsView: View {
}
}
}
.padding(.leading, 45)
Divider()
+39 -2
View File
@@ -12,6 +12,7 @@ struct SidebarView: View {
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var measurement: MeasurementWorkflowViewModel
@ObservedObject private var project: ProjectSession
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
@@ -28,6 +29,7 @@ struct SidebarView: View {
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
self._project = ObservedObject(wrappedValue: workflow.project)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
@@ -35,6 +37,21 @@ struct SidebarView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
presetBlock
mediaBlock
studioButtons
stepperAndProject
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
// children (#146); these Group blocks are layout-transparent, so
// visual order, ids and the 270 pt column are unchanged.
private var header: some View {
Group {
HStack {
Image("ICCery-logo")
.resizable()
@@ -63,7 +80,11 @@ struct SidebarView: View {
.padding(12)
Divider().overlay(Theme.border)
}
}
private var presetBlock: some View {
Group {
// Preset select (`#presetSelect`) issue #11. Selection
// applies the preset immediately; names render via Text only.
Picker("Preset", selection: Binding(
@@ -95,7 +116,11 @@ struct SidebarView: View {
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private var mediaBlock: some View {
Group {
// Media library (`#mediaSelect`) issue #146. Selection
// applies the recipe immediately, like presets; names render
// via Text only (#114). Never reuses `presetSelect` (#137).
@@ -145,7 +170,11 @@ struct SidebarView: View {
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private var studioButtons: some View {
Group {
// Calibrate Printer (`#btnCalibratePrinter`).
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
@@ -185,7 +214,11 @@ struct SidebarView: View {
showing: $showingAllHelp)
.accessibilityIdentifier("btnSpotRead")
.padding(.horizontal, 12)
}
}
private var stepperAndProject: some View {
Group {
Divider().overlay(Theme.border)
.padding(.vertical, 8)
@@ -205,9 +238,13 @@ struct SidebarView: View {
.padding(.horizontal, 6)
Spacer()
// Project chip (issue #149) a compact footer in the
// spacer's bottom, under the stepper. The 270 pt column
// cannot take four more large buttons (R10).
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
.padding(8)
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
}
+8 -1
View File
@@ -119,8 +119,11 @@ final class TargetWorkflowViewModel: ObservableObject {
@Published var print: PrintSessionViewModel!
/// Media recipe library needs a complete `self`.
@Published var media: MediaLibraryViewModel!
/// Spot-read console, created last needs `wizard` / `measurement`.
/// Spot-read console needs `wizard` / `measurement`.
@Published var spotRead: SpotReadViewModel!
/// Project file session (issue #149), created last needs `media`
/// for recipe apply and `spotRead` for the live-child check.
@Published var project: ProjectSession!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -148,6 +151,10 @@ final class TargetWorkflowViewModel: ObservableObject {
workflow: self,
environment: environment
)
self.project = ProjectSession(
workflow: self,
environment: environment
)
reloadPresets()
}
@@ -1,4 +1,5 @@
import Foundation
import Metal
import XCTest
@testable import ICCeryCore
@testable import ICCery
@@ -36,6 +37,15 @@ final class GamutViewModelTests: XCTestCase {
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
}
/// #147 `viewerUnavailable` is decided before any `SCNView` is
/// mounted: it must exactly mirror Metal presence on this host.
func testViewerUnavailableMirrorsMetalAvailability() async throws {
let vm = try makeViewModel()
XCTAssertEqual(
vm.viewerUnavailable,
MTLCreateSystemDefaultDevice() == nil)
}
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
@@ -0,0 +1,203 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #149 `.icceryproj` schema: snake_case keys, strict
/// `schema_version == 1`, and save-time refusal for empty/illegal
/// basename and empty/unsafe cwd (#59/#60/R11).
final class ICCeryProjectTests: XCTestCase {
private func tempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-proj-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
return dir
}
private func makeProject(
basename: String = "run-1",
cwd: String = "/tmp"
) -> ICCeryProject {
ICCeryProject(
name: "Run One",
basename: basename,
cwd: cwd,
profileBasename: "run-1",
printerID: "Mock_Queue",
printerDisplayName: "Mock Queue",
mediaRecipeID: "recipe-1",
presetID: "preset-std-rgb",
calibrationURL: "/tmp/r.cal",
lastVerification: VerificationSnapshot(
date: Date(timeIntervalSince1970: 1_700_000_000),
avgDE00: 0.8, maxDE00: 2.1,
status: "excellent", profileFilename: "run-1.icc"))
}
// MARK: - Round trip / keys
func testSnakeCaseRoundTrip() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
let project = makeProject()
try project.save(to: url)
let loaded = try ICCeryProject.load(from: url)
XCTAssertEqual(loaded.schemaVersion, 1)
XCTAssertEqual(loaded.basename, "run-1")
XCTAssertEqual(loaded.cwd, "/tmp")
XCTAssertEqual(loaded.profileBasename, "run-1")
XCTAssertEqual(loaded.printerID, "Mock_Queue")
XCTAssertEqual(loaded.mediaRecipeID, "recipe-1")
XCTAssertEqual(loaded.presetID, "preset-std-rgb")
XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal")
XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8)
XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1)
XCTAssertEqual(loaded.lastVerification?.status, "excellent")
XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc")
let raw = try String(contentsOf: url, encoding: .utf8)
for key in [
"\"schema_version\"", "\"profile_basename\"",
"\"printer_id\"", "\"printer_display_name\"",
"\"media_recipe_id\"", "\"preset_id\"",
"\"calibration_url\"", "\"last_verification\"",
"\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"",
] {
XCTAssertTrue(raw.contains(key), "missing key \(key)")
}
}
func testOptionalFieldsDecodeWhenAbsent() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("min.icceryproj")
let json = """
{
"schema_version": 1,
"name": "Minimal",
"basename": "abc",
"cwd": "/tmp",
"updated": "2026-09-13T00:00:00Z"
}
"""
try json.write(to: url, atomically: true, encoding: .utf8)
let loaded = try ICCeryProject.load(from: url)
XCTAssertEqual(loaded.basename, "abc")
XCTAssertNil(loaded.mediaRecipeID)
XCTAssertNil(loaded.presetID)
XCTAssertNil(loaded.lastVerification)
XCTAssertNil(loaded.calibrationURL)
}
// MARK: - Schema gate
func testSchemaVersion2Throws() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("v2.icceryproj")
try """
{"schema_version": 2, "name": "x", "basename": "abc",
"cwd": "/tmp", "future_field": [1, 2]}
""".write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
guard case ICCeryProject.ValidationError.unsupportedSchema(2)
= error else {
return XCTFail("expected unsupportedSchema, got \(error)")
}
XCTAssertEqual(
error.localizedDescription,
"This project file is not schema 1.")
}
}
func testMissingSchemaVersionThrows() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("noversion.icceryproj")
try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}"
.write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url))
}
// MARK: - Validation
func testEmptyBasenameRefusesSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
XCTAssertThrowsError(
try makeProject(basename: "").save(to: url)
) { error in
XCTAssertEqual(
error as? ICCeryProject.ValidationError, .emptyBasename)
}
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
func testIllegalBasenameRefusesSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
for bad in ["a/b", "a\\b", "..", "x..y"] {
XCTAssertThrowsError(
try makeProject(basename: bad).save(
to: dir.appendingPathComponent("p.icceryproj"))
) { error in
guard case ICCeryProject.ValidationError.invalidBasename
= error else {
return XCTFail("expected invalidBasename, got \(error)")
}
}
}
}
func testEmptyAndRelativeCwdRefuseSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in
XCTAssertEqual(
error as? ICCeryProject.ValidationError, .emptyCwd)
}
XCTAssertThrowsError(
try makeProject(cwd: "relative/dir").save(to: url)
) { error in
guard case ICCeryProject.ValidationError.unsafeCwd = error else {
return XCTFail("expected unsafeCwd, got \(error)")
}
}
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
func testIllegalBasenameRefusesOpen() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("bad.icceryproj")
try """
{"schema_version": 1, "basename": "a/b", "cwd": "/tmp"}
""".write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
guard case ICCeryProject.ValidationError.invalidBasename
= error else {
return XCTFail("expected invalidBasename, got \(error)")
}
}
}
func testEmptyNameFallsBackToBasename() throws {
let project = try ICCeryProject(
name: "", basename: "stem", cwd: "/tmp").validated()
XCTAssertEqual(project.name, "stem")
}
func testMissingFileThrowsOnOpen() {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("gone-\(UUID().uuidString).icceryproj")
XCTAssertThrowsError(try ICCeryProject.load(from: url))
}
}
@@ -0,0 +1,406 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #149 `ProjectSession` open/save/new/close against an
/// isolated `TestAppEnvironment`. Disk artefacts always win over the
/// project JSON (R18); a `CAL_` basename is never persisted (R11).
@MainActor
final class ProjectSessionTests: XCTestCase {
private var env: TestAppEnvironment!
private var workflow: TargetWorkflowViewModel!
private var project: ProjectSession!
private var cwd: URL!
override func setUp() async throws {
env = try TestAppEnvironment.make()
workflow = TargetWorkflowViewModel(environment: env.environment)
project = workflow.project
cwd = env.root.appendingPathComponent("proj-cwd")
try FileManager.default.createDirectory(
at: cwd, withIntermediateDirectories: true)
// Recents are pushed asynchronously; drain before asserting.
await flush()
}
override func tearDown() async throws {
env?.cleanup()
env = nil
workflow = nil
project = nil
cwd = nil
}
/// Lets pending `Task`s in the view models run.
private func flush() async {
try? await Task.sleep(nanoseconds: 100_000_000)
}
private func artefact(_ ext: String, stem: String = "job") throws {
try "x".write(
to: cwd.appendingPathComponent("\(stem).\(ext)"),
atomically: true, encoding: .utf8)
}
private func writeProjectFile(
_ name: String = "job.icceryproj",
basename: String = "job",
cwdOverride: String? = nil,
lastVerification: Bool = false,
mediaRecipeID: String? = nil,
presetID: String? = nil
) throws -> URL {
let snapshot: VerificationSnapshot? = lastVerification
? VerificationSnapshot(
date: Date(), avgDE00: 0.7, maxDE00: 1.9,
status: "excellent", profileFilename: "\(basename).icc")
: nil
let p = ICCeryProject(
name: "Fixture Project",
basename: basename,
cwd: cwdOverride ?? cwd.path,
mediaRecipeID: mediaRecipeID,
presetID: presetID,
lastVerification: snapshot)
let url = env.root.appendingPathComponent(name)
try p.save(to: url)
return url
}
// MARK: - Open
func testOpenAppliesBasenameCwdAndBinds() async throws {
try artefact("ti1")
try artefact("ti2")
try artefact("ti3")
let url = try writeProjectFile()
await project.openAsync(url)
XCTAssertEqual(workflow.wizard.basename, "job")
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
XCTAssertEqual(workflow.targetBasename, "job")
XCTAssertEqual(workflow.targetDirectory?.path, cwd.path)
XCTAssertEqual(project.projectURL, url)
XCTAssertEqual(project.project?.name, "Fixture Project")
XCTAssertEqual(project.windowTitle, "ICCery — Fixture Project")
XCTAssertFalse(project.isDirty)
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
}
func testOpenDiskWinsOverJsonStage() async throws {
// JSON claims a finished profile; disk only has .ti2 (R18).
try artefact("ti2")
let url = try writeProjectFile(lastVerification: true)
await project.openAsync(url)
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
XCTAssertTrue(project.diskBehindNotes)
let notice = workflow.wizard.notice
XCTAssertEqual(notice?.kind, .info)
XCTAssertTrue(
notice?.text.contains("artefacts on disk stop at .ti2") == true,
"got: \(notice?.text ?? "nil")")
}
func testOpenWithTi3ButNoIccLocksStage5Only() async throws {
try artefact("ti3")
let url = try writeProjectFile(lastVerification: true)
await project.openAsync(url)
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
XCTAssertFalse(workflow.wizard.isUnlocked(.verifyInstall))
XCTAssertTrue(project.diskBehindNotes)
}
func testOpenSchema2LeavesLiveStateUntouched() async throws {
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
let url = env.root.appendingPathComponent("v2.icceryproj")
try """
{"schema_version": 2, "basename": "other", "cwd": "\(cwd.path)"}
""".write(to: url, atomically: true, encoding: .utf8)
await project.openAsync(url)
XCTAssertNil(project.projectURL)
XCTAssertEqual(workflow.wizard.basename, "live")
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
XCTAssertTrue(
workflow.wizard.notice?.text.contains("not schema 1") == true)
}
func testOpenMissingCwdPresentsRelocateSheet() async throws {
let gone = env.root.appendingPathComponent("no-such-dir").path
let url = try writeProjectFile(cwdOverride: gone)
workflow.wizard.setTarget(basename: "live", workingDirectory: cwd)
await project.openAsync(url)
XCTAssertTrue(project.showingRelocateSheet)
XCTAssertNotNil(project.pendingRelocate)
XCTAssertNil(project.projectURL)
XCTAssertEqual(workflow.wizard.basename, "live")
}
func testRelocateCancelAbortsOpen() async throws {
let gone = env.root.appendingPathComponent("no-such-dir").path
let url = try writeProjectFile(cwdOverride: gone)
await project.openAsync(url)
project.cancelRelocate()
XCTAssertFalse(project.showingRelocateSheet)
XCTAssertNil(project.projectURL)
}
func testOpenUnknownMediaRecipeStillOpens() async throws {
let url = try writeProjectFile(mediaRecipeID: "recipe-absent")
await project.openAsync(url)
await flush()
XCTAssertEqual(project.projectURL, url)
XCTAssertEqual(workflow.wizard.basename, "job")
// The unknown recipe was ignored, not fatal.
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
}
func testOpenAppliesPresetWhenNoRecipe() async throws {
let url = try writeProjectFile(presetID: "preset-std-rgb")
await project.openAsync(url)
await flush()
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
}
// MARK: - Save
func testSaveRefusesEmptyBasename() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.wizard.basename = ""
XCTAssertFalse(project.canSave)
let saved = await project.saveProjectAsync()
XCTAssertFalse(saved)
// The file keeps its original basename.
let reloaded = try ICCeryProject.load(from: url)
XCTAssertEqual(reloaded.basename, "job")
}
func testSaveRefusesEmptyCwd() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.wizard.workingDirectory = nil
XCTAssertFalse(project.canSave)
let saved = await project.saveProjectAsync()
XCTAssertFalse(saved)
}
func testCalBasenameRefusedWithoutPersistedOriginal() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
// Bare CAL_ stem no persisted original to trust (R11).
workflow.wizard.basename = "CAL_job"
workflow.wizard.calibrationOriginalBasename = ""
XCTAssertTrue(project.canSave) // enabled so the banner shows
let saved = await project.saveProjectAsync()
XCTAssertFalse(saved)
XCTAssertTrue(
workflow.wizard.notice?.text.contains(
"Finish or exit calibration") == true)
let raw = try String(contentsOf: url, encoding: .utf8)
XCTAssertFalse(raw.contains("CAL_"))
XCTAssertEqual(try ICCeryProject.load(from: url).basename, "job")
}
func testCalBasenameSavesPersistedOriginal() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.wizard.basename = "CAL_job"
workflow.wizard.calibrationOriginalBasename = "job"
let saved = await project.saveProjectAsync()
XCTAssertTrue(saved)
let reloaded = try ICCeryProject.load(from: url)
XCTAssertEqual(reloaded.basename, "job")
XCTAssertFalse(rawContainsCal(url))
}
private func rawContainsCal(_ url: URL) -> Bool {
guard let raw = try? String(contentsOf: url, encoding: .utf8) else {
return false
}
return raw.contains("CAL_")
}
func testSaveCapturesLiveFields() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
workflow.profile.calibrationFile = "/tmp/live.cal"
XCTAssertTrue(project.isDirty)
await flush() // title recompute is deferred one main turn
XCTAssertTrue(project.windowTitle.hasSuffix(""))
let saved = await project.saveProjectAsync()
XCTAssertTrue(saved)
let reloaded = try ICCeryProject.load(from: url)
XCTAssertEqual(reloaded.printerID, "Mock_Queue")
XCTAssertEqual(
reloaded.printerDisplayName, "Mock Queue Display")
XCTAssertEqual(reloaded.presetID, "preset-std-rgb")
XCTAssertEqual(reloaded.calibrationURL, "/tmp/live.cal")
XCTAssertFalse(project.isDirty)
}
// MARK: - New / Close
func testNewClearsBasenameKeepsArtefacts() async throws {
try artefact("ti3")
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
XCTAssertTrue(workflow.wizard.isUnlocked(.buildProfile))
project.requestNew()
XCTAssertTrue(project.showingNewAlert)
project.confirmNew()
XCTAssertEqual(workflow.wizard.basename, "")
XCTAssertEqual(workflow.targetBasename, "")
XCTAssertNil(project.projectURL)
XCTAssertEqual(project.windowTitle, "ICCery")
XCTAssertEqual(workflow.media.selectedRecipeID, "none")
XCTAssertEqual(workflow.wizard.sessionMode, .profile)
// The artefact is untouched; the stepper just can't see it.
XCTAssertTrue(
FileManager.default.fileExists(
atPath: cwd.appendingPathComponent("job.ti3").path))
XCTAssertFalse(workflow.wizard.isUnlocked(.buildProfile))
}
func testNewWhileChildLiveBannersInstead() async throws {
workflow.measurement.isChartreadRunning = true
project.requestNew()
XCTAssertFalse(project.showingNewAlert)
XCTAssertNotNil(workflow.wizard.notice)
}
func testDirtyNewShowsDirtyAlert() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.wizard.basename = "changed"
project.requestNew()
XCTAssertFalse(project.showingNewAlert)
XCTAssertTrue(project.showingDirtyAlert)
// Don't Save New proceeds.
project.resolveDirty(save: false)
XCTAssertEqual(workflow.wizard.basename, "")
XCTAssertNil(project.projectURL)
}
func testCloseKeepsLiveSession() async throws {
try artefact("ti1")
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
project.requestClose()
XCTAssertNil(project.projectURL)
XCTAssertEqual(workflow.wizard.basename, "job")
XCTAssertEqual(workflow.wizard.workingDirectory?.path, cwd.path)
XCTAssertEqual(project.windowTitle, "ICCery")
}
func testDirtyCloseSaveThenCloses() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
await flush()
workflow.wizard.basename = "renamed"
project.requestClose()
XCTAssertTrue(project.showingDirtyAlert)
project.resolveDirty(save: true)
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertNil(project.projectURL)
XCTAssertEqual(try ICCeryProject.load(from: url).basename,
"renamed")
}
// MARK: - Report / recents
func testSaveReportWritesMarkdown() async throws {
try artefact("ti1")
try artefact("ti3")
let url = try writeProjectFile(lastVerification: true)
await project.openAsync(url)
await flush()
project.saveReport()
try await Task.sleep(nanoseconds: 300_000_000)
let report = cwd.appendingPathComponent("job-report.md")
XCTAssertTrue(FileManager.default.fileExists(atPath: report.path))
let text = try String(contentsOf: report, encoding: .utf8)
XCTAssertTrue(text.contains("job.ti1 | exists"))
XCTAssertTrue(text.contains("job.ti2 | missing"))
XCTAssertTrue(text.contains("job.ti3 | exists"))
XCTAssertTrue(text.contains("avg ΔE₀₀ 0.70"))
XCTAssertTrue(
workflow.wizard.notice?.text.contains("job-report.md") == true)
}
func testOpenPushesRecent() async throws {
let url = try writeProjectFile()
await project.openAsync(url)
try await Task.sleep(nanoseconds: 300_000_000)
let recents = project.recents
XCTAssertEqual(recents.first?.path, url.path)
XCTAssertEqual(recents.first?.name, "Fixture Project")
}
func testOpenRecentMissingFileDropped() async throws {
let store = env.environment.recentProjectsStore
let ghost = env.root.appendingPathComponent("ghost.icceryproj")
try await store.add(url: ghost, name: "Ghost")
project.openRecent(
RecentProjectEntry(name: "Ghost", path: ghost.path))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertTrue(
workflow.wizard.notice?.text.contains("gone") == true)
let loaded = try await store.load()
XCTAssertTrue(loaded.isEmpty)
XCTAssertNil(project.projectURL)
}
}
@@ -0,0 +1,134 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #149 `recent_projects.json`: cap 20, newest first, dedupe
/// by path, missing files pruned on submenu build, Clear Menu wipes the
/// recents file only, corrupt file kept (R12).
final class RecentProjectsStoreTests: XCTestCase {
private var root: URL!
override func setUp() async throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-recents-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: root, withIntermediateDirectories: true)
}
override func tearDown() async throws {
if let root { try? FileManager.default.removeItem(at: root) }
root = nil
}
private var storeURL: URL {
root.appendingPathComponent("recent_projects.json")
}
private func projectFile(_ name: String) throws -> URL {
let url = root.appendingPathComponent("\(name).icceryproj")
try "{}".write(to: url, atomically: true, encoding: .utf8)
return url
}
func testCapTwentyNewestFirst() async throws {
let store = RecentProjectsStore(url: storeURL)
for i in 0..<25 {
let url = try projectFile("p\(i)")
try await store.add(url: url, name: "P\(i)")
}
let all = try await store.load()
XCTAssertEqual(all.count, 20)
XCTAssertEqual(all.first?.name, "P24")
XCTAssertEqual(all.last?.name, "P5")
}
func testAddDeduplicatesByPath() async throws {
let store = RecentProjectsStore(url: storeURL)
let url = try projectFile("dup")
try await store.add(url: url, name: "First")
try await store.add(url: try projectFile("other"), name: "Other")
try await store.add(url: url, name: "Second")
let all = try await store.load()
XCTAssertEqual(all.count, 2)
XCTAssertEqual(all.first?.name, "Second")
XCTAssertEqual(
all.filter { $0.path == url.path }.count, 1)
}
func testPruneMissingDropsGoneFiles() async throws {
let store = RecentProjectsStore(url: storeURL)
let live = try projectFile("live")
try await store.add(url: live, name: "Live")
try await store.add(
url: root.appendingPathComponent("gone.icceryproj"),
name: "Gone")
let pruned = try await store.pruneMissing()
XCTAssertEqual(pruned.count, 1)
XCTAssertEqual(pruned.first?.name, "Live")
// The rewrite persisted the drop.
let reloaded = try await RecentProjectsStore(url: storeURL).load()
XCTAssertEqual(reloaded.count, 1)
}
func testRemoveByPath() async throws {
let store = RecentProjectsStore(url: storeURL)
let url = try projectFile("a")
try await store.add(url: url, name: "A")
let removed = try await store.remove(path: url.path)
XCTAssertTrue(removed)
let loaded = try await store.load()
XCTAssertTrue(loaded.isEmpty)
let second = try await store.remove(path: url.path)
XCTAssertFalse(second)
}
func testClearWipesRecentsFileOnly() async throws {
let store = RecentProjectsStore(url: storeURL)
let projectURL = try projectFile("keep")
try await store.add(url: projectURL, name: "Keep")
try await store.clear()
let loaded = try await store.load()
XCTAssertTrue(loaded.isEmpty)
// The `.icceryproj` itself survives Clear Menu.
XCTAssertTrue(
FileManager.default.fileExists(atPath: projectURL.path))
let raw = try String(contentsOf: storeURL, encoding: .utf8)
XCTAssertTrue(raw.contains("["))
}
func testCorruptFileThrowsAndKeepsBytes() async throws {
try "not json".write(
to: storeURL, atomically: true, encoding: .utf8)
let store = RecentProjectsStore(url: storeURL)
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.load()
}
XCTAssertEqual(
try String(contentsOf: storeURL, encoding: .utf8), "not json")
}
func testEntryStoresBookmarkAndPath() async throws {
let store = RecentProjectsStore(url: storeURL)
let url = try projectFile("b")
try await store.add(url: url, name: "B")
let entry = try await store.load().first
XCTAssertEqual(entry?.path, url.path)
XCTAssertNotNil(entry?.bookmark)
XCTAssertFalse(entry?.bookmarkHash.isEmpty ?? true)
}
func testBookmarkHashStableAcrossStores() async throws {
let a = RecentProjectEntry(name: "x", path: "/tmp/p.icceryproj")
let b = RecentProjectEntry(name: "y", path: "/tmp/p.icceryproj")
XCTAssertEqual(a.bookmarkHash, b.bookmarkHash)
let c = RecentProjectEntry(name: "z", path: "/tmp/q.icceryproj")
XCTAssertNotEqual(a.bookmarkHash, c.bookmarkHash)
}
}
@@ -22,6 +22,9 @@ struct TestAppEnvironment {
var mediaLibraryURL: URL {
root.appendingPathComponent("media_library.json")
}
var recentProjectsURL: URL {
root.appendingPathComponent("recent_projects.json")
}
/// Creates an isolated environment under `NSTemporaryDirectory()`.
/// Call `cleanup()` when finished.
@@ -65,6 +68,9 @@ struct TestAppEnvironment {
),
mediaStore: MediaLibraryStore(
url: root.appendingPathComponent("media_library.json")
),
recentProjectsStore: RecentProjectsStore(
url: root.appendingPathComponent("recent_projects.json")
)
)
return TestAppEnvironment(root: root, environment: environment)
@@ -1,4 +1,5 @@
import Foundation
import Metal
import XCTest
/// Milestone 10 Issue #147 gamut compare chrome tests.
@@ -9,6 +10,11 @@ import XCTest
@MainActor
final class Milestone10GamutCompareUITests: XCTestCase {
/// Metal on the test host the app under test runs on the same
/// machine, so this predicts whether the sheet mounts SceneKit.
/// GPU-less runners still get the banner/Close assertions (#147).
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
@@ -46,6 +52,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
}
override func tearDown() async throws {
// Never leave the gamut sheet up for `terminate()` (#147).
if app != nil, element("btnCloseGamut").exists {
element("btnCloseGamut").click()
}
app?.terminate()
app = nil
if let testRoot {
@@ -75,6 +85,22 @@ final class Milestone10GamutCompareUITests: XCTestCase {
return el
}
/// Exists **and** `isEnabled`. Layer toggles render as disabled
/// placeholders until the async layer load lands on the macOS 12
/// runner `waitFor` alone wins the race against `parse`.
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists && el.isEnabled { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(
el.exists && el.isEnabled, "Expected enabled element \(id)")
return el
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
@@ -88,18 +114,41 @@ final class Milestone10GamutCompareUITests: XCTestCase {
_ = waitFor("gamutView")
}
/// Inverse of `waitFor` polls until the element leaves the tree.
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !element(id).exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
}
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
/// is not stuck behind a key sheet (#147). No-op when already closed.
private func closeGamutSheet() {
let close = element("btnCloseGamut")
guard close.waitForExistence(timeout: 5) else { return }
close.click()
waitForGone("gamutView")
}
func testLayerTogglesExistWithSRGB() throws {
openGamutSheet()
let srgb = waitFor("gamutLayer-sRGB")
let srgb = waitUntilEnabled("gamutLayer-sRGB")
XCTAssertTrue(srgb.exists)
XCTAssertTrue(srgb.isEnabled)
// NSButton checkbox value is 1 when checked.
XCTAssertEqual(srgb.value as? Int, 1, "sRGB layer should be on")
let compare = waitFor("gamutLayer-compare")
XCTAssertTrue(compare.exists)
XCTAssertFalse(compare.isEnabled, "Compare toggle must be disabled before a load")
let status = waitFor("gamutStatusText")
let statusValue = status.value as? String ?? ""
XCTAssertTrue(statusValue.contains("sRGB"), "Status should list the sRGB layer, got: \(statusValue)")
closeGamutSheet()
}
func testAddCompareButtonExists() throws {
@@ -113,6 +162,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let status = waitFor("gamutStatusText")
let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("sRGB"), "Status should keep the sRGB clause, got: \(value)")
closeGamutSheet()
}
func testCompareGamLoadEnablesToggle() throws {
@@ -124,8 +174,10 @@ final class Milestone10GamutCompareUITests: XCTestCase {
waitFor("btnGamutAddCompare").click()
waitFor("btnGamutOpenGam").click()
let compare = waitFor("gamutLayer-compare")
// The pre-load placeholder also exists wait for enabled.
let compare = waitUntilEnabled("gamutLayer-compare")
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after load")
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after load")
let status = waitFor("gamutStatusText")
let value = status.value as? String ?? ""
@@ -133,6 +185,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
let remove = waitFor("btnGamutRemoveCompare")
XCTAssertTrue(remove.isEnabled)
closeGamutSheet()
}
func testOpenProfileRunsIccgamutForCompare() throws {
@@ -146,8 +199,15 @@ final class Milestone10GamutCompareUITests: XCTestCase {
waitFor("btnGamutAddCompare").click()
waitFor("btnGamutOpenProfile").click()
let compare = waitFor("gamutLayer-compare")
let compare = waitUntilEnabled("gamutLayer-compare")
XCTAssertTrue(compare.isEnabled, "Compare toggle should enable after iccgamut")
XCTAssertEqual(compare.value as? Int, 1, "Compare layer should be on after iccgamut")
// The compare slot's display name is the .gam stem ("myprinter").
let status = waitFor("gamutStatusText")
let statusValue = status.value as? String ?? ""
XCTAssertTrue(statusValue.contains("myprinter"), "Status should list the compare layer, got: \(statusValue)")
closeGamutSheet()
}
func testInspectPanelIdleStableHeight() throws {
@@ -157,6 +217,7 @@ final class Milestone10GamutCompareUITests: XCTestCase {
XCTAssertTrue(panel.exists)
XCTAssertTrue(element("gamutInspectIdle").exists)
XCTAssertTrue(element("gamutStatusText").exists)
closeGamutSheet()
}
func testManualLabInspectShowsContainment() throws {
@@ -176,11 +237,55 @@ final class Milestone10GamutCompareUITests: XCTestCase {
XCTAssertTrue(value.contains("in"), "Lab(50,0,0) should be inside sRGB, got: \(value)")
XCTAssertTrue(element("gamutInspectL").exists)
XCTAssertTrue(element("gamutInspectSwatch").exists)
closeGamutSheet()
}
func testResetIdentifierUnchanged() throws {
openGamutSheet()
let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled)
closeGamutSheet()
}
/// `btnCloseGamut` is always enabled including on the fallback
/// banner and dismisses the sheet (#147).
func testCloseButtonDismissesSheet() throws {
openGamutSheet()
let close = waitFor("btnCloseGamut")
XCTAssertTrue(close.isEnabled)
close.click()
waitForGone("gamutView")
}
/// The fallback banner exists exactly when the host lacks Metal
/// no `SCNView` is mounted on a GPU-less runner, and none may be
/// reported unavailable on a GPU host.
func testFallbackBannerMatchesGPUAvailability() throws {
openGamutSheet()
if hasGPU {
XCTAssertFalse(
element("gamutViewerUnavailable").exists,
"GPU host must mount the SceneKit view, not the fallback")
} else {
_ = waitFor("gamutViewerUnavailable")
}
closeGamutSheet()
}
/// `ICCERY_TEST_SKIP_SCENEKIT=1` forces the fallback even on a GPU
/// host banner plus a working Close, no `SCNView` mounted (#147).
/// The env is set for this test only; the default launch env must
/// not carry it, or CI's future GPU run would skip SceneKit too.
func testForcedSceneKitSkipShowsBannerAndClose() throws {
app.launchEnvironment["ICCERY_TEST_SKIP_SCENEKIT"] = "1"
openGamutSheet()
_ = waitFor("gamutViewerUnavailable")
let close = waitFor("btnCloseGamut")
XCTAssertTrue(close.isEnabled)
close.click()
waitForGone("gamutView")
}
}
@@ -0,0 +1,263 @@
import XCTest
/// Milestone 10 UI tests issue #149 project file. Panels are never
/// real: `ICCERY_TEST_PROJECT_OPEN` / `ICCERY_TEST_PROJECT_SAVE`
/// inject fixture paths through `UITestHooks`. Menu commands are driven
/// through the File menu when it is in the AX tree, else by their
/// keyboard shortcuts (N) the tests do not depend on menu AX
/// exposure (R19). All queries by identifier.
@MainActor
final class Milestone10ProjectUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui10p-\(UUID().uuidString)")
workDir = testRoot.appendingPathComponent("WorkDir")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
try FileManager.default.createDirectory(
at: testRoot.appendingPathComponent("AppData"),
withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
workDir = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
private func noticeText(timeout: TimeInterval = 10) -> String {
let el = waitFor("noticeText", timeout: timeout)
return (el.value as? String) ?? el.label
}
/// Alert/sheet button by visible title, falling back to the a11y
/// id; nil when neither matches. macOS 12 SwiftUI alerts often
/// drop `accessibilityIdentifier` on their buttons, so the title
/// is the reliable handle there.
private func alertButton(title: String, id: String) -> XCUIElement? {
let inDialog = app.dialogs.firstMatch.buttons[title].firstMatch
if inDialog.exists { return inDialog }
let inSheet = app.sheets.firstMatch.buttons[title].firstMatch
if inSheet.exists { return inSheet }
let byId = element(id)
return byId.exists ? byId : nil
}
/// Fires File New Project via the menu when it is in the AX
/// tree, else N. On macOS 12 `typeKey` may not reach the
/// `CommandGroup`, and menu item ids are unreliable the menu
/// item is matched by its "New Project" label first.
private func triggerNewProject() {
let fileMenu = app.menuBarItems["File"]
if fileMenu.waitForExistence(timeout: 5) {
fileMenu.click()
let byTitle = app.menuItems["New Project"].firstMatch
let byId = app.menuItems["menuProjectNew"].firstMatch
let item = byTitle.exists ? byTitle : byId
if item.waitForExistence(timeout: 5) {
item.click()
return
}
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
}
app.typeKey("n", modifierFlags: .command)
}
/// Writes a `.icceryproj` fixture under `testRoot` and points the
/// open-picker hook at it.
private func stageProjectFixture(
basename: String = "ui149job",
lastVerification: Bool = false
) throws -> URL {
let verification = lastVerification ? """
"last_verification": {
"date": "2026-09-12T00:00:00Z",
"avg_de00": 0.7,
"max_de00": 1.9,
"status": "excellent",
"profile_filename": "\(basename).icc"
},
""" : ""
let json = """
{
"schema_version": 1,
"name": "UI Fixture Project",
"notes": "",
"basename": "\(basename)",
"cwd": "\(workDir.path)",
"printer_id": null,
"media_recipe_id": null,
"preset_id": null,
"calibration_url": null,
\(verification)
"updated": "2026-09-13T00:00:00Z"
}
"""
let url = testRoot.appendingPathComponent("fixture.icceryproj")
try json.write(to: url, atomically: true, encoding: .utf8)
app.launchEnvironment["ICCERY_TEST_PROJECT_OPEN"] = url.path
return url
}
private func artefact(_ ext: String, stem: String = "ui149job") throws {
try "x".write(
to: workDir.appendingPathComponent("\(stem).\(ext)"),
atomically: true, encoding: .utf8)
}
// MARK: - Tests
func testNewProjectClearsBasenameDoesNotDeleteFixtureTi3() throws {
try artefact("ti3")
_ = try stageProjectFixture()
launchApp()
// Open the fixture via the chip never a real panel.
waitFor("btnProjectOpen").click()
_ = waitFor("projectChipPath")
let basenameField = app.textFields["targetBasename"]
XCTAssertTrue(basenameField.waitForExistence(timeout: 10))
XCTAssertEqual(basenameField.value as? String, "ui149job")
// File New Project when the menu is in the AX tree, else
// N. Mock CUPS may have enumerated a queue that the fixture
// does not record, making the session dirty in that case
// the dirty alert gates New first. macOS 12 alerts often lack
// button identifiers, so confirm by title with id fallback.
triggerNewProject()
let deadline = Date().addingTimeInterval(10)
var confirmed = false
while Date() < deadline {
// Dirty sessions show the dirty alert first; discarding it
// runs the New reset directly (no second confirm).
if let discard = alertButton(
title: "Don't Save", id: "btnProjectDirtyDiscard") {
discard.click()
confirmed = true
break
}
if let start = alertButton(
title: "Start", id: "btnProjectNewConfirm") {
start.click()
confirmed = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(confirmed, "expected the New or dirty alert")
// Basename cleared (legal "no target" state not a
// placeholder), fixture `.ti3` untouched on disk.
let cleared = basenameField.value as? String ?? ""
XCTAssertEqual(cleared, "")
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("ui149job.ti3").path))
XCTAssertTrue(element("projectChip").exists)
XCTAssertEqual(
element("projectChipName").value as? String, "No project")
}
func testOpenProjectDiskWinsOverJsonStage() throws {
// JSON claims a finished profile; disk stops at .ti2 (R18).
try artefact("ti2")
_ = try stageProjectFixture(lastVerification: true)
launchApp()
waitFor("btnProjectOpen").click()
let notice = noticeText()
XCTAssertTrue(
notice.contains("artefacts on disk stop at .ti2"),
"got: \(notice)")
XCTAssertTrue(element("projectChipStale").exists)
XCTAssertEqual(
element("projectChipPath").value as? String, workDir.lastPathComponent)
}
func testSaveDisabledWithoutBasename() throws {
launchApp()
_ = waitFor("projectChip")
XCTAssertEqual(element("projectChipName").value as? String, "No project")
// Chip Save is hidden while unbound; Save As lives in the menu.
XCTAssertFalse(element("btnProjectSave").exists)
// When the File menu is in the AX tree, Save must be disabled.
let fileMenu = app.menuBarItems["File"]
if fileMenu.waitForExistence(timeout: 3) {
fileMenu.click()
let save = app.menuItems["menuProjectSave"].firstMatch
if save.waitForExistence(timeout: 3) {
XCTAssertFalse(save.isEnabled)
}
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
}
}
func testCalBasenameRefused() throws {
// A fixture whose stem is CAL_-prefixed binds a live CAL_
// basename; the persisted original is empty, so Save must
// refuse and never write CAL_ back (R11).
let url = try stageProjectFixture(basename: "CAL_ui149")
let before = try Data(contentsOf: url)
launchApp()
waitFor("btnProjectOpen").click()
let save = waitFor("btnProjectSave")
XCTAssertTrue(save.isEnabled)
save.click()
XCTAssertTrue(
noticeText().contains("Finish or exit calibration"),
"got: \(noticeText())")
XCTAssertEqual(try Data(contentsOf: url), before)
}
}
+34 -2
View File
@@ -85,6 +85,31 @@ final class Milestone2UITests: XCTestCase {
return el
}
/// Exists **and** `isEnabled` guards clicks against buttons that
/// appear a beat before their `.disabled` condition clears.
private func waitUntilEnabled(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists && el.isEnabled { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(
el.exists && el.isEnabled, "Expected enabled element \(id)")
return el
}
/// Non-asserting existence poll for the retry-or-fail pattern.
private func existsAfter(_ id: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if element(id).exists { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return element(id).exists
}
/// Assert an element stays absent after a short dwell unlike
/// `waitForExistence`, which always burns its full timeout on the
/// negative path.
@@ -181,8 +206,15 @@ final class Milestone2UITests: XCTestCase {
XCTAssertTrue(element("tiffDpi").exists)
XCTAssertTrue(element("targetLabelPreview").exists)
app.buttons["btnCreateLayout"].click()
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
// On the slow macOS 12 runner a synthesized click can land
// while the button is still rebuilding retry once if the
// gallery never materialises, then allow a generous window
// for the fixture printtarg + PNG render.
waitUntilEnabled("btnCreateLayout").click()
if !existsAfter("galleryPage-0", timeout: 15) {
waitUntilEnabled("btnCreateLayout").click()
}
XCTAssertTrue(waitFor("galleryPage-0", timeout: 30).exists)
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
+60 -27
View File
@@ -113,6 +113,59 @@ final class Milestone3UITests: XCTestCase {
return recordedLpArgv()
}
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
/// moves up, clear of the Dock collision zone at the window's
/// bottom edge (#132).
///
/// macOS overlay scrollbars are not in the AX tree never use
/// `app.scrollBars` and a synthesized scroll wheel is inert on
/// this LazyVGrid, so the scroll is a real drag on the gallery
/// cell's content. A stale/off-screen AX frame resolves to a screen
/// point that can be a Dock icon a coordinate click there once
/// opened Calendar instead of Print. Callers must click only when
/// the returned element `isHittable`; never coordinate-click a
/// stale frame.
@discardableResult
private func scrollStage2UntilHittable(
_ identifier: String,
timeout: TimeInterval = 20
) -> XCUIElement {
var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let windowBottom = app.windows.firstMatch.frame.maxY
if button.exists, button.isHittable,
button.frame.maxY < windowBottom - 80 {
return button
}
// Grab the upper half of the cell (the TIFF, not the Print
// button / Dock) and drag toward the top of the window.
// Mouse moves UP gallery content moves UP Print leaves
// the Dock zone.
if cell.isHittable {
let start = cell.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.25))
let end = start.withOffset(CGVector(dx: 0, dy: -280))
start.press(forDuration: 0.15, thenDragTo: end)
} else {
// Cell not hit-testable: drag the stage-2 content
// directly still content, still never scrollBars.
let scrollView = app.scrollViews["stage-2"]
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55))
.press(forDuration: 0.15, thenDragTo:
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier]
}
return button
}
// MARK: - Tests
/// Panel appears after the manifest; refresh populates the printer
@@ -199,36 +252,16 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
// The gallery cell's Print button sits at the window's bottom
// edge where synthesized scroll-wheel events are inert on the
// LazyVGrid (#132). Drag the NSScrollView's vertical AXScrollBar
// thumb instead a real scroll that re-renders the cell onscreen.
var printPage = app.buttons["btnPrintPage-0"]
let scrollDeadline = Date().addingTimeInterval(15)
while !printPage.isHittable, Date() < scrollDeadline {
let scroller = app.scrollBars.allElementsBoundByIndex
.first { $0.frame.height > $0.frame.width }
if let scroller {
scroller.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.1))
.press(forDuration: 0.1, thenDragTo:
scroller.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.6)))
} else {
app.scrollViews["stage-2"].scroll(byDeltaX: 0, deltaY: -1)
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
printPage = app.buttons["btnPrintPage-0"]
}
if printPage.isHittable {
printPage.click()
} else {
// LazyVGrid cells can report a stale a11y frame click the
// point directly; the lp argv assert below still verifies.
// edge; scroll until it is genuinely hittable (#132). Never
// coordinate-click a stale frame that point can be the Dock.
let printPage = scrollStage2UntilHittable("btnPrintPage-0")
guard printPage.isHittable else {
print("AXTREE-BEGIN frame=\(printPage.frame)\n" +
"\(app.debugDescription)\nAXTREE-END")
printPage.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.5)).click()
XCTFail("btnPrintPage-0 never became hittable; frame=\(printPage.frame)")
return
}
printPage.click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
XCTAssertTrue(argv.contains("page1.tif"), argv)
@@ -86,6 +86,60 @@ final class Milestone6CalibrationUITests: XCTestCase {
}
}
/// Stage 0 must not push the sidebar off-screen: the macOS `Form`
/// rows with expanding spacers once gave the stage an unbounded ideal
/// width, and window centering shifted the 270 pt sidebar into
/// negative X (issue #163). AX-tree existence checks cannot see that,
/// so assert real frame geometry.
func testCalibrationViewDoesNotOverflowWindow() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let window = app.windows.firstMatch
XCTAssertTrue(window.exists)
XCTAssertGreaterThanOrEqual(calButton.frame.minX, 0)
XCTAssertLessThanOrEqual(calButton.frame.maxX, window.frame.maxX)
let ret = app.buttons["btnCalReturn"]
XCTAssertTrue(ret.waitForExistence(timeout: 5))
XCTAssertTrue(ret.isHittable)
}
/// "Return to Profiling" is the single Stage 0 exit and carries the
/// cancel-action shortcut, so Escape must dismiss the dashboard too
/// (issue #163). `typeKey` delivery is unreliable on the macOS 12 CI
/// runner (m10 phase-08), so the Escape check falls back to the
/// deterministic button tap.
func testCalibrationReturnButtonAndEscapeDismiss() throws {
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 10))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
let returnButton = app.buttons["btnCalReturn"]
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
XCTAssertTrue(returnButton.isHittable)
returnButton.tap()
let stage1 = app.descendants(matching: .any)["stage-1"]
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
// Re-enter and try Escape; fall back to the button where the
// runtime does not deliver typeKey.
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
if !stage1.waitForExistence(timeout: 4) {
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
returnButton.tap()
XCTAssertTrue(stage1.waitForExistence(timeout: 5))
}
}
/// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80).
func testCalibrationTargenFailureRestoresBasename() throws {
@@ -1,10 +1,15 @@
import Foundation
import Metal
import XCTest
/// Milestone 6 Issue #28 native SceneKit gamut viewer acceptance tests.
@MainActor
final class Milestone6GamutUITests: XCTestCase {
/// Metal on the test host the app under test runs on the same
/// machine, so this predicts whether the sheet mounts SceneKit.
private var hasGPU: Bool { MTLCreateSystemDefaultDevice() != nil }
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
@@ -64,6 +69,10 @@ final class Milestone6GamutUITests: XCTestCase {
}
override func tearDown() async throws {
// Never leave the gamut sheet up for `terminate()` (#147).
if app != nil, element("btnCloseGamut").exists {
element("btnCloseGamut").click()
}
app?.terminate()
app = nil
if let testRoot {
@@ -90,10 +99,29 @@ final class Milestone6GamutUITests: XCTestCase {
return el
}
/// Inverse of `waitFor` polls until the element leaves the tree.
private func waitForGone(_ id: String, timeout: TimeInterval = 10) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !element(id).exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(element(id).exists, "Expected element \(id) to disappear")
}
/// `btnCloseGamut` dismisses the sheet so `tearDown`'s `terminate()`
/// is not stuck behind a key sheet (#147). No-op when already closed.
private func closeGamutSheet() {
let close = element("btnCloseGamut")
guard close.waitForExistence(timeout: 5) else { return }
close.click()
waitForGone("gamutView")
}
/// Build and verify the mock profile, then open the native gamut viewer.
/// The viewer should load both the reference sRGB mesh and the profile
/// gamut copied from that reference.
func testViewGamutOpensSceneKitSheet() throws {
private func openGamutSheet() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
@@ -105,6 +133,12 @@ final class Milestone6GamutUITests: XCTestCase {
waitFor("btnViewGamut").click()
_ = waitFor("gamutView")
}
func testViewGamutOpensSceneKitSheet() throws {
openGamutSheet()
let gamutView = waitFor("gamutView")
XCTAssertTrue(gamutView.exists)
@@ -112,9 +146,33 @@ final class Milestone6GamutUITests: XCTestCase {
let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
// The fallback banner appears exactly when the host lacks Metal
// no SCNView is constructed without a GPU (#147).
if hasGPU {
XCTAssertFalse(
element("gamutViewerUnavailable").exists,
"GPU host must mount the SceneKit view, not the fallback")
} else {
_ = waitFor("gamutViewerUnavailable")
}
// The reset button demonstrates that the viewer is interactive.
let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled)
closeGamutSheet()
}
/// Clicking Reset drives the live `SCNView` runs only on Metal
/// hosts, skipped on GPU-less runners so the same suite exercises
/// 3D once CI has a GPU (#147).
func testResetCameraInteractsWithScene() throws {
guard hasGPU else { throw XCTSkip("No Metal") }
openGamutSheet()
let reset = waitFor("btnResetGamutCamera")
reset.click()
closeGamutSheet()
}
}
+192
View File
@@ -0,0 +1,192 @@
import Foundation
import XCTest
/// Settings sheet UI tests (issue #165).
///
/// The Verification thresholds once shared one non-wrapping `HStack` and
/// drew past the sheet's right clip on the macOS grouped `Form`. AX
/// existence cannot see clipping (#163), so containment is asserted on
/// real frame geometry against the sheet's bounds.
///
/// All three numeric fields also passed their default value as the
/// `TextField` label; inside an `HStack` row that label renders inline
/// it is not a placeholder producing "Stale after 30 [30] days". The
/// fields are now direct `Form` children, so the descriptive label
/// renders once in the label column and the box fills the control
/// column; `testNumericFieldsCarryLabelsNotDuplicatedValues` pins it.
@MainActor
final class SettingsUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() async throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
app.launch()
app.activate()
}
override func tearDown() async throws {
app?.terminate()
app = nil
}
/// Sheet content lives under `app.sheets`, outside the main window's
/// a11y tree (Milestone2 pattern).
private var sheet: XCUIElement {
app.sheets.firstMatch
}
private func openSettings() {
let gear = app.buttons["openSettingsBtn"]
XCTAssertTrue(gear.waitForExistence(timeout: 10))
gear.click()
XCTAssertTrue(sheet.waitForExistence(timeout: 10))
}
private func thresholdField(_ fieldID: String) -> XCUIElement {
let field = sheet.textFields[fieldID]
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(fieldID)")
return field
}
private func replaceFieldValue(_ field: XCUIElement, with text: String) {
field.click()
app.typeKey("a", modifierFlags: .command)
field.typeText(text)
}
private func waitForSheetDismiss(timeout: TimeInterval = 10) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !sheet.exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(sheet.exists, "Expected sheet to disappear")
}
/// Both ΔE rows must render fully inside the 560×620 sheet: the
/// label-column `StaticText`s and the control-column fields all sit
/// within the sheet bounds, the two fields share the Form's control
/// column margin, and the Warning row sits below the Good row so the
/// two cannot overlap on one clipped line.
///
/// The numeric fields are direct `Form` children, so macOS lifts
/// each `TextField` label into the right-aligned label column and
/// the editable box fills the control column the same layout the
/// Pickers use. The control column ends only ~3.5 pt inside the
/// sheet (PopUpButtons reach it too), so the right-edge assertion is
/// "inside the sheet", not the older 12 pt compact-field inset.
func testVerificationRowsStayInsideSheet() throws {
openSettings()
let goodField = thresholdField("settingsDeltaEGood")
let warningField = thresholdField("settingsDeltaEWarning")
// Labels render as sibling staticTexts in the label column.
let goodLabel = sheet.staticTexts["Good ΔE ≤"]
let warningLabel = sheet.staticTexts["Warning ΔE ≤"]
XCTAssertTrue(goodLabel.waitForExistence(timeout: 5))
XCTAssertTrue(warningLabel.waitForExistence(timeout: 5))
// Left boundary: labels must be inside the sheet with >=12 pt inset
XCTAssertTrue(goodLabel.frame.minX >= sheet.frame.minX + 12.0)
XCTAssertTrue(warningLabel.frame.minX >= sheet.frame.minX + 12.0)
// Right boundary: fields must not draw past the sheet's clip
XCTAssertTrue(
goodField.frame.maxX <= sheet.frame.maxX,
"Good ΔE field clips the sheet's right edge")
XCTAssertTrue(
warningField.frame.maxX <= sheet.frame.maxX,
"Warning ΔE field clips the sheet's right edge")
// Vertical separation
XCTAssertTrue(
warningField.frame.minY > goodField.frame.minY,
"thresholds must be two separate rows")
// Both threshold fields align at the same control column margin
XCTAssertTrue(
abs(goodField.frame.minX - warningField.frame.minX) <= 1.0,
"Good and Warning ΔE fields should align at the same column margin")
// Other labels must not overflow the left boundary
let defaultInstLabel = sheet.staticTexts["Default instrument"]
XCTAssertTrue(defaultInstLabel.waitForExistence(timeout: 5))
XCTAssertTrue(
defaultInstLabel.frame.minX >= sheet.frame.minX + 12.0,
"Default instrument label must not overflow left edge")
let bundledSidecarsLabel = sheet.staticTexts["Bundled sidecars"]
XCTAssertTrue(bundledSidecarsLabel.waitForExistence(timeout: 5))
XCTAssertTrue(
bundledSidecarsLabel.frame.minX >= sheet.frame.minX + 12.0,
"Bundled sidecars label must not overflow left edge")
sheet.buttons["Cancel"].click()
waitForSheetDismiss(timeout: 5)
}
/// `warning <= good` fails `AppSettings.validate()` and keeps the
/// sheet open with the contract error text; restoring valid values
/// lets Save dismiss (issue #165 acceptance, strings are the #5
/// contract).
func testDeltaEValidationBlocksSaveThenValidSaveDismisses() throws {
openSettings()
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "1")
sheet.buttons["Save"].click()
let error = sheet.staticTexts[
"Good ΔE threshold must be strictly less than the warning threshold."
]
XCTAssertTrue(error.waitForExistence(timeout: 10))
XCTAssertTrue(sheet.exists, "invalid ΔE must not dismiss the sheet")
replaceFieldValue(thresholdField("settingsDeltaEWarning"), with: "5")
sheet.buttons["Save"].click()
waitForSheetDismiss(timeout: 10)
}
/// macOS renders a `TextField`'s first argument as a label, not a
/// placeholder inside the old `HStack` rows it drew inline, so the
/// sheet read "Stale after 30 [30] days" / "Good ΔE 2.0 [2.0]".
/// As direct `Form` children each label now renders exactly once, in
/// the label column; no `staticText` may echo the field's value.
func testNumericFieldsCarryLabelsNotDuplicatedValues() throws {
openSettings()
// (identifier, label-column text, rendered default value, the
// literal that used to double-render as the field's label)
// `value:` shows the formatted number 2.0 renders as "2".
let rows: [(id: String, label: String, value: String, dup: String)] = [
("settingsDeltaEGood", "Good ΔE ≤", "2", "2.0"),
("settingsDeltaEWarning", "Warning ΔE ≤", "5", "5.0"),
("settingsCalStaleDays", "Stale after (days)", "30", "30"),
]
for spec in rows {
let field = sheet.textFields[spec.id]
XCTAssertTrue(field.waitForExistence(timeout: 10), "missing \(spec.id)")
XCTAssertEqual(
field.value as? String, spec.value,
"\(spec.id) default value changed unexpectedly")
XCTAssertTrue(
sheet.staticTexts[spec.label].waitForExistence(timeout: 5),
"\(spec.id) must render \"\(spec.label)\" once in the label column")
for ghost in Set([spec.value, spec.dup]) {
XCTAssertFalse(
sheet.staticTexts[ghost].exists,
"\(spec.id) must not render \"\(ghost)\" as a second label")
}
XCTAssertTrue(
field.frame.maxX <= sheet.frame.maxX,
"\(spec.id) field clips the sheet's right edge")
}
sheet.buttons["Cancel"].click()
waitForSheetDismiss(timeout: 5)
}
}
+6
View File
@@ -46,3 +46,9 @@ Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents
## No `"test_target"` fallbacks (#60)
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
## Project file (#149)
A `.icceryproj` is a JSON **index** (`schema_version` 1) over `basename` + `cwd` + optional `media_recipe_id`/`preset_id`/`calibration_url`/`printer_*` + a `last_verification` ΔE₀₀ snapshot. It is never a second source of truth: opening one writes `WizardState` and re-runs the same artefact probe window focus uses — if JSON claims Stage 5 but disk stops at `.ti2`, the stepper follows the disk and the sidebar chip shows `projectChipStale` with an info banner.
`wizard_state.json` remains the crash-resume file; there is no auto-save. Recents live in `appDataDir/recent_projects.json` (cap 20, bookmark + path, corrupt → `[]` + keep bytes). `Save Report…` writes `{basename}-report.md` in cwd atomically. A live `CAL_` basename is never persisted — Save resolves the persisted `calibrationOriginalBasename` or refuses with a banner.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@ Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. I
| Field | Default | Notes |
|-------|---------|-------|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
| `default_instrument` | `null` | Stored; seeds the Spot Read instrument picker when that instrument is present (#148). **Never** written into `printtarg -i` or `targen` argv. Stage 2 `#instrumentSelect` remains the printtarg instrument. |
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
+1 -1
View File
@@ -25,7 +25,7 @@ Cone-only mark for window/taskbar. Raster set:
| File | Use |
|------|-----|
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
| `Resources/dmg-background.png` (+ `@2x`; source `brand/dmg-background.svg`) | macOS DMG window. `scripts/package-release.sh` builds a HiDPI TIFF and passes it to `dmgbuild==1.6.7` from `build/.venv-dmgbuild` (created by `INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`). Monterey Python 3.9 needs `PIP_IGNORE_REQUIRES_PYTHON=1` or pip will keep 1.6.5. Missing art is a hard fail (#95, #189). |
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
+2
View File
@@ -21,6 +21,8 @@ targets:
- ICCery.entitlements
- ICCery.Debug.entitlements
- Argyll
- dmg-background.png
- dmg-background@2x.png
- path: Resources/Argyll
type: folder
dependencies:
+7 -2
View File
@@ -30,6 +30,11 @@ if [ -z "$TAG" ] || [ "$TAG" = "${GITHUB_REF:-}" ]; then
exit 1
fi
case "$TAG" in
*prerelease*) PRERELEASE=true ;;
*) PRERELEASE=false ;;
esac
DMG="${1:-}"
if [ -z "$DMG" ]; then
DMG="$(ls -1 ICCery-*.dmg 2>/dev/null | head -n 1 || true)"
@@ -49,12 +54,12 @@ STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
"$API/repos/$REPO/releases/tags/$TAG" || true)"
if [ "$STATUS" = "404" ]; then
echo "==> Creating release $TAG"
echo "==> Creating release $TAG (prerelease=$PRERELEASE)"
STATUS="$(curl -sS -o "$BODY" -w '%{http_code}' \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-X POST "$API/repos/$REPO/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$PRERELEASE,\"target_commitish\":\"${GITHUB_SHA:-}\"}")"
fi
if [ "$STATUS" != "200" ] && [ "$STATUS" != "201" ]; then
echo "error: could not load/create release $TAG (HTTP $STATUS)" >&2
+17 -9
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env python3
# scripts/dmgbuild-settings.py
#
# dmgbuild settings for ICCery. Set DMG_APP, DMG_FILENAME and DMG_VOLUME_NAME
# in the environment, or accept the defaults. Background art can be supplied
# later by placing a PNG at Resources/dmg-background.png and setting
# DMG_BACKGROUND.
# dmgbuild settings for ICCery. scripts/package-release.sh exports
# DMG_APP, DMG_FILENAME, DMG_VOLUME_NAME, and DMG_BACKGROUND (a
# HiDPI TIFF). A missing background is a hard error — a grey
# Finder window is not an acceptable release artefact (#95).
import os
import sys
@@ -23,15 +23,23 @@ if not app_path or not app_path.endswith('.app') or not os.path.isdir(app_path):
files = [app_path]
# Background art is optional. If the referenced PNG does not exist, fall back
# to a plain window. See docs/23-assets.md for the DMG background spec.
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
if background and not os.path.exists(background):
background = None
# Finder on Sonoma+ is picky about PNG-with-alpha window pictures and
# about classic Alias Manager blobs. package-release.sh always passes
# a flattened HiDPI TIFF as DMG_BACKGROUND. dmgbuild 1.6.7 (bookmark
# .DS_Store) needs Python >= 3.10, which the Monterey runner does not
# have; 1.6.5 + TIFF is what CI can ship (#95).
background = os.environ.get('DMG_BACKGROUND', '')
if not background or not os.path.isfile(background):
sys.stderr.write(
'error: DMG_BACKGROUND must point at an existing image '
'(got %r)\n' % background)
sys.exit(1)
icon = None
# Window size is enough for the app icon and the Applications alias.
# Bitmap is slightly larger than this rect so title-bar chrome on
# 14+ does not crop the wordmark.
window_rect = ((100, 100), (660, 400))
# Use icon view without extra chrome.
+66 -6
View File
@@ -2,19 +2,79 @@
# scripts/ensure-host-tools.sh
#
# Bootstrap host tools needed by CI on the macOS 12 runner:
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
# - xcodegen: always. Pinned prebuilt release from GitHub (Homebrew's
# current formula requires Xcode 15.3, which cannot be installed on
# macOS 12).
# - dmgbuild: only when INSTALL_DMGBUILD=1 or --dmgbuild. Isolated in
# build/.venv-dmgbuild so the test job never pip-installs it.
#
# Safe to run repeatedly: existing tools are left alone.
# dmgbuild 1.6.6+, ds_store 1.3.2+ and mac_alias 2.2.3 declare
# Requires-Python >= 3.10. The wheels are py3-none-any and run on the
# runner's 3.9; PIP_IGNORE_REQUIRES_PYTHON is required or pip will only
# offer 1.6.5 and keep a cached venv on that version (#95).
# pip itself is capped at <26.1: 26.1+ needs Python 3.10.
#
# Safe to run repeatedly: existing tools are left alone unless the
# dmgbuild pin is not met.
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
XCODEGEN_VERSION="2.38.0"
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
VENV="$ROOT/build/.venv-dmgbuild"
DMGBUILD_PIN="1.6.7"
echo "==> Ensuring dmgbuild"
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
INSTALL_DMGBUILD="${INSTALL_DMGBUILD:-0}"
for arg in "$@"; do
case "$arg" in
--dmgbuild) INSTALL_DMGBUILD=1 ;;
esac
done
if [ "$INSTALL_DMGBUILD" = "1" ]; then
echo "==> Ensuring dmgbuild==$DMGBUILD_PIN in $VENV"
mkdir -p "$ROOT/build"
# pip 26.1+ requires Python 3.10 (dataclass slots). A leftover
# `pip install --upgrade pip` on this 3.9 venv installed 26.2.1 and
# the next pip invocation crashed. Recreate if pip is already dead.
if [ -x "$VENV/bin/python" ] \
&& ! "$VENV/bin/python" -m pip --version >/dev/null 2>&1; then
echo "==> venv pip is broken; recreating $VENV"
rm -rf "$VENV"
fi
if [ ! -x "$VENV/bin/python" ]; then
python3 -m venv "$VENV"
fi
# Without this, pip on Python 3.9 hides 1.6.6+ and leaves 1.6.5.
PIP_IGNORE_REQUIRES_PYTHON=1
export PIP_IGNORE_REQUIRES_PYTHON
"$VENV/bin/python" -m pip install --upgrade 'pip>=24.3,<26.1'
"$VENV/bin/python" -m pip install --upgrade --force-reinstall \
"dmgbuild==$DMGBUILD_PIN" \
'ds_store>=1.3.3' \
'mac_alias>=2.2.3'
"$VENV/bin/python" -c 'from importlib.metadata import version
print("dmgbuild", version("dmgbuild"))
print("ds_store", version("ds_store"))
print("mac_alias", version("mac_alias"))
parts=[]
for p in version("dmgbuild").split("."):
try:
parts.append(int("".join(c for c in p if c.isdigit()) or "0"))
except ValueError:
parts.append(0)
parts += [0, 0, 0]
raise SystemExit(0 if tuple(parts[:3]) >= (1, 6, 7) else 1)
'
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$VENV/bin" >> "$GITHUB_PATH"
fi
PATH="$VENV/bin:$PATH"
export PATH
else
echo "==> Skipping dmgbuild (set INSTALL_DMGBUILD=1 for the package job)"
fi
if command -v xcodegen >/dev/null 2>&1; then
echo "==> xcodegen already on PATH: $(xcodegen --version)"
+28 -7
View File
@@ -103,19 +103,39 @@ EOF
scripts/verify-sidecar-signatures.sh "$APP"
fi
echo "==> Installing / locating dmgbuild"
echo "==> Locating dmgbuild"
# The package CI job already ran INSTALL_DMGBUILD=1 ensure-host-tools.sh,
# which created build/.venv-dmgbuild and prepended it to PATH. Local
# runs bootstrap the same venv if dmgbuild is missing.
VENV="$ROOT/build/.venv-dmgbuild"
if ! command -v dmgbuild >/dev/null 2>&1; then
VENV="$ROOT/build/.venv-dmgbuild"
if [ ! -d "$VENV/bin" ]; then
python3 -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip
"$VENV/bin/pip" install dmgbuild
if [ ! -x "$VENV/bin/dmgbuild" ]; then
INSTALL_DMGBUILD=1 "$ROOT/scripts/ensure-host-tools.sh" --dmgbuild
fi
PATH="$VENV/bin:$PATH"
export PATH
fi
if ! command -v dmgbuild >/dev/null 2>&1; then
echo "error: dmgbuild not available. Try 'python3 -m venv .venv && pip install dmgbuild'" >&2
echo "error: dmgbuild not on PATH; run INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh" >&2
exit 1
fi
echo "dmgbuild $(command -v dmgbuild)"
if [ -x "$VENV/bin/python" ]; then
"$VENV/bin/python" -c 'from importlib.metadata import version; print("dmgbuild", version("dmgbuild"))'
fi
PNG1X="$ROOT/Resources/dmg-background.png"
PNG2X="$ROOT/Resources/dmg-background@2x.png"
if [ ! -f "$PNG1X" ] || [ ! -f "$PNG2X" ]; then
echo "error: missing $PNG1X or $PNG2X" >&2
exit 1
fi
mkdir -p "$ROOT/build"
DMG_BACKGROUND="$ROOT/build/dmg-background.tiff"
echo "==> Building HiDPI DMG background TIFF"
tiffutil -cathidpicheck "$PNG1X" "$PNG2X" -out "$DMG_BACKGROUND"
if [ ! -f "$DMG_BACKGROUND" ]; then
echo "error: tiffutil did not write $DMG_BACKGROUND" >&2
exit 1
fi
@@ -128,6 +148,7 @@ VOLUME_NAME="ICCery ${VERSION}"
DMG_APP="$APP" \
DMG_FILENAME="$DMG" \
DMG_VOLUME_NAME="$VOLUME_NAME" \
DMG_BACKGROUND="$DMG_BACKGROUND" \
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
echo "DMG: $PWD/$DMG"