Compare commits

...
Author SHA1 Message Date
gronod 45fd2b988f Merge pull request 'fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)' (#110) from feat/109-ci-xcodegen-pin into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m34s
macOS CI / package (push) Skipped
Reviewed-on: #110
2026-09-12 08:35:51 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c6ad1e6ce fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)
brew install xcodegen resolves to a formula requiring Xcode 15.3, which
cannot be installed on the macOS 12 runner (Xcode 14.2), failing run
31390 before project generation. Install a pinned prebuilt XcodeGen
2.38.0 release via scripts/ensure-host-tools.sh, shared by the
build-and-test and package jobs (package-release.sh also runs xcodegen).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:34:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9857ddb4d0 test(m9): retry dropped calibration generate tap in UI test
macOS CI / build-and-test (push) Failing after 48s
macOS CI / package (push) Skipped
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 22:38:30 +01:00
gronod 7a6b82816b Merge pull request 'feat(m9): state management and view demotions for macOS 12 (Slice 4)' (#108) from feat/m9-slice4-state-and-views into milestone/m9-monterey 2026-09-11 21:57:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00
gronod a76120d9f7 Merge pull request 'test(m9): complete migration of stateful and async suites to XCTest (Slice 3)' (#107) from feat/m9-slice3-xctest-async-stateful into milestone/m9-monterey 2026-09-11 21:34:15 +01:00
24 changed files with 376 additions and 213 deletions
+9 -3
View File
@@ -24,10 +24,11 @@ jobs:
- name: Assert Xcode 14 toolchain - name: Assert Xcode 14 toolchain
run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1) run: xcodebuild -version | grep -E "Xcode 14." || (echo "Unexpected Xcode version" && exit 1)
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead.
- name: Ensure host tools - name: Ensure host tools
run: | run: scripts/ensure-host-tools.sh
command -v xcodegen || brew install xcodegen
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
- name: Generate Xcode project - name: Generate Xcode project
run: xcodegen generate --spec project.yml run: xcodegen generate --spec project.yml
@@ -134,6 +135,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
- name: Ensure host tools
run: scripts/ensure-host-tools.sh
- name: Package release - name: Package release
run: scripts/package-release.sh run: scripts/package-release.sh
env: env:
+3 -4
View File
@@ -3,8 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@Bindable var model: CalibrationViewModel @ObservedObject var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel @ObservedObject var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -73,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL { if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() } .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle") .accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)") Text("Loaded: \(url.lastPathComponent)")
@@ -96,7 +96,6 @@ struct CalibrationView: View {
} }
} }
} }
.formStyle(.grouped)
HStack { HStack {
Spacer() Spacer()
+12 -13
View File
@@ -1,13 +1,12 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and /// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves. /// compute `.cal` curves.
@MainActor @MainActor
@Observable final class CalibrationViewModel: ObservableObject {
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel let profile: ProfileWorkflowViewModel
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
// MARK: - Form state // MARK: - Form state
var colourSpace: ColourSpace = .cmyk @Published var colourSpace: ColourSpace = .cmyk
var steps: Int = 21 @Published var steps: Int = 21
var whitePatches: Int = 4 @Published var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false @Published var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320" @Published var inkLimit: String = "320"
var applyToProfile: Bool = false @Published var applyToProfile: Bool = false
var computedCalURL: URL? @Published var computedCalURL: URL?
var calibrationLog: [String] = [] @Published var calibrationLog: [String] = []
var isGenerating = false @Published var isGenerating = false
var isComputing = false @Published var isComputing = false
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
+30 -7
View File
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth. /// (blue-yellow) is depth.
struct GamutView: View { struct GamutView: View {
@State private var viewModel: GamutViewModel @StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) { init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
} }
var body: some View { var body: some View {
@@ -87,11 +87,6 @@ struct GamutView: View {
) )
.focusable() .focusable()
.focused($isFocused) .focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true } .onAppear { isFocused = true }
VStack { VStack {
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView context.coordinator.scnView = scnView
context.coordinator.scene = scene context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.installKeyMonitor()
return scnView return scnView
} }
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
} }
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
nsView.isPlaying = false nsView.isPlaying = false
} }
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject { final class Coordinator: NSObject {
weak var scnView: SCNView? weak var scnView: SCNView?
weak var scene: SCNScene? weak var scene: SCNScene?
private var keyMonitor: Any?
private let profileNode = SCNNode() private let profileNode = SCNNode()
private let referenceGroup = SCNNode() private let referenceGroup = SCNNode()
@@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false scnView?.isPlaying = false
} }
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() { func resetCamera() {
guard let scnView else { return } guard let scnView else { return }
+6 -7
View File
@@ -1,26 +1,25 @@
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer. /// View model for the native SceneKit gamut viewer.
/// ///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory. /// printer/profile `.gam` from the current working directory.
@MainActor @MainActor
@Observable final class GamutViewModel: ObservableObject {
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh. /// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh? @Published var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh. /// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh? @Published var profileMesh: GamutMesh?
/// User-facing status line. /// User-facing status line.
var status = "Loading gamut…" @Published var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset. /// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {} @Published var resetCamera: () -> Void = {}
private let profileGamURL: URL? private let profileGamURL: URL?
+26 -10
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main @main
struct ICCeryApp: App { struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var workflow: TargetWorkflowViewModel @StateObject private var workflow: TargetWorkflowViewModel
init() { init() {
let environment = AppEnvironment.live() let environment = AppEnvironment.live()
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories() try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at // Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save. // startup (#158); the Settings sheet re-applies on save.
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
} }
var body: some Scene { var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
Window("ICCery", id: "main") { // metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
RootView(workflow: workflow) RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
.defaultSize(width: 1280, height: 800) .commands {
.windowResizability(.contentMinSize) // Single-window app: no File > New window.
.defaultPosition(.center) CommandGroup(replacing: .newItem) {}
}
} }
} }
@@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI `Window` scenes launched by XCTest stay // SwiftUI scenes launched by XCTest stay `.runningBackground`
// `.runningBackground` unless the app takes regular activation // unless the app takes regular activation and orders the window
// and orders the window front (CI run 29804). // front (CI run 29804).
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
for window in NSApp.windows { for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
private func configureMainWindow(_ window: NSWindow) {
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22). /// Stage 3 workflow state and interaction (issues #18#22).
@MainActor @MainActor
@Observable final class MeasurementWorkflowViewModel: ObservableObject {
final class MeasurementWorkflowViewModel {
// MARK: - Authorities // MARK: - Authorities
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
// MARK: - Settings-driven thresholds // MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0 @Published private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0 @Published private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false @Published private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection // MARK: - Instrument detection
var instruments: [InstrumentDevice] = [] @Published var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto @Published var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false @Published var isDetecting = false
var detectionError: String? @Published var detectionError: String?
// MARK: - Chartread session // MARK: - Chartread session
var isChartreadRunning = false @Published var isChartreadRunning = false
var chartreadState: ChartreadState = .idle @Published var chartreadState: ChartreadState = .idle
var currentPrompt: String? @Published var currentPrompt: String?
var requestedWarningKey: String? @Published var requestedWarningKey: String?
var chartreadLog: [String] = [] @Published var chartreadLog: [String] = []
var rows: [ChartreadRow] = [] @Published var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] @Published var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false @Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80). /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice? @Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
var passSnapshots: [URL] = [] @Published var passSnapshots: [URL] = []
var isFinishing = false @Published var isFinishing = false
var finishNotice: Notice? @Published var finishNotice: Notice?
var resumedFromTi2 = false @Published var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom /// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114). /// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View { struct SavePresetDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export. /// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View { struct ManagePresetsDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -1,23 +1,22 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85). /// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor @MainActor
@Observable final class PrintSessionViewModel: ObservableObject {
final class PrintSessionViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
var printers: [Printer] = [] @Published var printers: [Printer] = []
var selectedPrinter = "" @Published var selectedPrinter = ""
var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
var selectedTray: Int? @Published var selectedTray: Int?
var selectedMediaType: String? @Published var selectedMediaType: String?
var printOrientation = "portrait" @Published var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:] @Published var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice? @Published var printNotice: Notice?
var isPrinting = false @Published var isPrinting = false
private var printTask: Task<Void, Never>? private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
+30 -31
View File
@@ -1,12 +1,11 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install. /// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor @MainActor
@Observable final class ProfileWorkflowViewModel: ObservableObject {
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 4 form // MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m @Published var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u @Published var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4 @Published var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none @Published var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = "" @Published var fwaCustomPath: String = ""
var illuminant: String = "" @Published var illuminant: String = ""
var observer: String = "" @Published var observer: String = ""
var inputViewingCond: String = "" @Published var inputViewingCond: String = ""
var outputViewingCond: String = "" @Published var outputViewingCond: String = ""
var profileDescription: String = "" @Published var profileDescription: String = ""
var copyright: String = "" @Published var copyright: String = ""
// MARK: - Run state // MARK: - Run state
var isColprofRunning = false @Published var isColprofRunning = false
var colprofLog: [String] = [] @Published var colprofLog: [String] = []
var colprofProgress: String? @Published var colprofProgress: String?
var createdProfileURL: URL? @Published var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL? @Published var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24) // MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false @Published var applyCalibration = false
var calibrationFile: String = "" @Published var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25) // MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport? @Published var profcheckReport: ProfcheckReport?
var profcheckWarning: String? @Published var profcheckWarning: String?
var isProfcheckRunning = false @Published var isProfcheckRunning = false
// MARK: - History / drift (issue #26) // MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = [] @Published var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil @Published var driftPrinterFilter: String? = nil
var driftAlert: String? @Published var driftAlert: String?
var isHistoryStoreError: String? @Published var isHistoryStoreError: String?
// MARK: - Install (issue #27) // MARK: - Install (issue #27)
var installResult: InstallProfileResult? @Published var installResult: InstallProfileResult?
var showingInstallCollision = false @Published var showingInstallCollision = false
var installCollisionMessage: String = "" @Published var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions? var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
+11 -5
View File
@@ -5,12 +5,18 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification /// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell). /// banner pinned to the top (docs/21 §Shell).
struct RootView: View { struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false @State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard } init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -64,11 +70,11 @@ struct RootView: View {
} }
/// Content for the active wizard stage. Isolated into its own view so that /// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's /// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View { private struct WizardStageContent: View {
@Bindable var model: WizardViewModel @ObservedObject var model: WizardViewModel
var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
switch model.stage { switch model.stage {
+1 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields. /// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View { struct SettingsView: View {
@State var model = SettingsViewModel() @StateObject var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [ private static let instruments: [(code: String, label: String)] = [
@@ -143,7 +143,6 @@ struct SettingsView: View {
} }
} }
} }
.formStyle(.grouped)
Divider() Divider()
+5 -5
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
@@ -6,12 +7,11 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a /// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20. /// `settingsDidChange` notification fans out to #20.
@MainActor @MainActor
@Observable final class SettingsViewModel: ObservableObject {
final class SettingsViewModel {
var settings: AppSettings @Published var settings: AppSettings
var validationErrors: [String] = [] @Published var validationErrors: [String] = []
var savedFlash = false @Published var savedFlash = false
private let store: SettingsStore private let store: SettingsStore
private let sink: LogSink private let sink: LogSink
+18 -2
View File
@@ -4,12 +4,28 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper. /// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View { struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard } init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility /// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable. /// identifiers so the UI-test contract stays stable.
struct Stage1View: View { struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
ScrollView { ScrollView {
+36 -6
View File
@@ -5,7 +5,19 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert /// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3. /// real spooling lands in M3.
struct Stage2View: View { struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -240,7 +252,7 @@ struct Stage2View: View {
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _, _ in .onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
@@ -328,9 +340,18 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel") .accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) { .onAppear { schedulePrinterRefresh() }
// Auto-enumerate once a manifest exists and whenever it .onChange(of: workflow.printtargResult?.pages.count) { _ in
// changes (e.g. resume from .ti2). schedulePrinterRefresh()
}
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
if workflow.print.printers.isEmpty, workflow.printtargResult != nil { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters() workflow.print.refreshPrinters()
} }
@@ -341,7 +362,16 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button. /// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View { private struct GalleryPageView: View {
let page: GalleryPage let page: GalleryPage
let workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
+9 -1
View File
@@ -4,7 +4,15 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging. /// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View { struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel @ObservedObject var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`. /// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View { struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel @ObservedObject var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install. /// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View { struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel @ObservedObject var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+58 -59
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset /// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,8 +10,7 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only /// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back. /// coalesced log batches and completion hop back.
@MainActor @MainActor
@Observable final class TargetWorkflowViewModel: ObservableObject {
final class TargetWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -19,95 +18,95 @@ final class TargetWorkflowViewModel {
// MARK: - Stage 1 form (targen) // MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb { @Published var colourSpace: ColourSpace = .rgb {
didSet { didSet {
guard colourSpace != oldValue else { return } guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08). // CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4 blackPatches = colourSpace == .cmyk ? 0 : 4
} }
} }
var patchPreset: PatchCountPreset = .standard800 @Published var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`. /// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500 @Published var customPatchCount = 2500
var whitePatches = 4 @Published var whitePatches = 4
var blackPatches = 4 @Published var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched // Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in). // control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false @Published var greyStepsEnabled = false
var greySteps = 5 @Published var greySteps = 5
var singleChannelEnabled = false @Published var singleChannelEnabled = false
var singleChannelSteps = 5 @Published var singleChannelSteps = 5
var neutralStepsEnabled = false @Published var neutralStepsEnabled = false
var neutralSteps = 3 @Published var neutralSteps = 3
var neutralConcEnabled = false @Published var neutralConcEnabled = false
var neutralConcentration = 0.50 @Published var neutralConcentration = 0.50
var preconditioningProfile: String? @Published var preconditioningProfile: String?
var highQuality = false @Published var highQuality = false
var adaptationEnabled = false @Published var adaptationEnabled = false
var adaptation = 0.10 @Published var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps @Published var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false @Published var inkLimitEnabled = false
var totalInkLimit = 320 @Published var totalInkLimit = 320
var darkEmphasisEnabled = false @Published var darkEmphasisEnabled = false
var darkEmphasis = 1.0 @Published var darkEmphasis = 1.0
var devicePowerEnabled = false @Published var devicePowerEnabled = false
var devicePower = 1.0 @Published var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60). /// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = "" @Published var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd. /// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL? @Published var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg) // MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1 @Published var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4 @Published var pageSize: PageSize = .a4
var customPageW = 210.0 @Published var customPageW = 210.0
var customPageH = 297.0 @Published var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight @Published var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft /// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here). /// regression must be visible here).
var tiffDpi = 300 @Published var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic @Published var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1 @Published var customSeed = 1
var labelIsCustom = false @Published var labelIsCustom = false
var customLabel = "" @Published var customLabel = ""
var metaPrinter = "" @Published var metaPrinter = ""
var metaInkSet = "" @Published var metaInkSet = ""
var metaDriverPaper = "" @Published var metaDriverPaper = ""
var metaActualPaper = "" @Published var metaActualPaper = ""
// MARK: - Run state // MARK: - Run state
var targenRunning = false @Published var targenRunning = false
var targenLog: [String] = [] @Published var targenLog: [String] = []
var printtargRunning = false @Published var printtargRunning = false
var printtargLog: [String] = [] @Published var printtargLog: [String] = []
var printtargResult: PrinttargResult? @Published var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on /// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false @Published var resumedFromTi2 = false
// MARK: - Presets // MARK: - Presets
var presets: [ProfilingPreset] = [] @Published var presets: [ProfilingPreset] = []
var selectedPresetID = "none" @Published var selectedPresetID = "none"
var showingSavePreset = false @Published var showingSavePreset = false
var showingManagePresets = false @Published var showingManagePresets = false
var savePresetName = "" @Published var savePresetName = ""
var savePresetDesc = "" @Published var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists /// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes. /// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel @Published var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists /// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values. /// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel @Published var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
var calibration: CalibrationViewModel! @Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session. /// Stage 2 unmanaged print session.
var print: PrintSessionViewModel! @Published var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
+13 -14
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06). /// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,47 +10,46 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons. /// navigation is disk, not buttons.
@MainActor @MainActor
@Observable final class WizardViewModel: ObservableObject {
final class WizardViewModel {
// MARK: - wizardState fields (persisted) // MARK: - wizardState fields (persisted)
var stage: WizardStage { @Published var stage: WizardStage {
didSet { if stage != oldValue { persist() } } didSet { if stage != oldValue { persist() } }
} }
/// `wizardState.basename` empty until a real artefact names it (#60). /// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String { @Published var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } } didSet { if basename != oldValue { refreshGating(); persist() } }
} }
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59). /// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? { @Published var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } } didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
} }
var printerName: String? { @Published var printerName: String? {
didSet { if printerName != oldValue { persist() } } didSet { if printerName != oldValue { persist() } }
} }
var sessionMode: SessionMode { @Published var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } } didSet { if sessionMode != oldValue { persist() } }
} }
/// `profileBasename` may differ after a `.ti3` import (#94). /// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? { @Published var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } } didSet { if profileBasename != oldValue { persist() } }
} }
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String { @Published var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } } didSet { if calibrationOriginalBasename != oldValue { persist() } }
} }
// MARK: - Ephemeral // MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`). /// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice? @Published var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts() @Published private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28). /// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false @Published var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference. /// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL? @Published var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase {
s.modeResolver = { name in s.modeResolver = { name in
if Self.missing.contains(name) { return nil } if Self.missing.contains(name) { return nil }
Self.currentSymbol = name Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String)) ColorSyncSuppressorTests.recorded.append(
if let ok = Self.succeeding, (ColorSyncSuppressorTests.currentSymbol, modeArg as String))
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0 return 0
} }
return 1 return 1
@@ -76,7 +76,14 @@ final class Milestone6CalibrationUITests: XCTestCase {
// After generation the wizard should advance to Stage 2 (layout) because // After generation the wizard should advance to Stage 2 (layout) because
// a CAL_ .ti1 now exists and the session is in calibration mode. // a CAL_ .ti1 now exists and the session is in calibration mode.
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
XCTAssertTrue(layout.waitForExistence(timeout: 25)) if !layout.waitForExistence(timeout: 25) {
// The generate tap can be dropped while the dashboard is still
// settling after the stage transition; retry once before failing.
if calGenerate.waitForExistence(timeout: 2) {
calGenerate.tap()
}
XCTAssertTrue(layout.waitForExistence(timeout: 25))
}
} }
/// A failing calibration targen surfaces the error through the /// A failing calibration targen surfaces the error through the
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# scripts/ensure-host-tools.sh
#
# Bootstrap host tools needed by CI on the macOS 12 runner:
# - xcodegen: pinned prebuilt release from GitHub (Homebrew's current
# formula requires Xcode 15.3, which cannot be installed on macOS 12).
# - dmgbuild: via pip3 (used by scripts/package-release.sh).
#
# Safe to run repeatedly: existing tools are left alone.
set -eu
XCODEGEN_VERSION="2.38.0"
INSTALL_ROOT="${XCODEGEN_HOME:-$HOME/.local/xcodegen/$XCODEGEN_VERSION}"
echo "==> Ensuring dmgbuild"
python3 -c "import dmgbuild" 2>/dev/null || pip3 install dmgbuild
if command -v xcodegen >/dev/null 2>&1; then
echo "==> xcodegen already on PATH: $(xcodegen --version)"
else
echo "==> Installing xcodegen $XCODEGEN_VERSION (prebuilt)"
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
ZIP="$TMP/xcodegen-$XCODEGEN_VERSION.zip"
curl -fL --retry 3 \
"https://github.com/yonaskolb/XcodeGen/releases/download/$XCODEGEN_VERSION/xcodegen.zip" \
-o "$ZIP"
rm -rf "$INSTALL_ROOT"
mkdir -p "$INSTALL_ROOT"
# Zip contains xcodegen/{bin/xcodegen,share/xcodegen/SettingPresets};
# XcodeGen resolves its presets relative to the binary, so keep the tree.
unzip -q "$ZIP" -d "$INSTALL_ROOT"
BIN_DIR="$INSTALL_ROOT/xcodegen/bin"
chmod +x "$BIN_DIR/xcodegen"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$BIN_DIR" >> "$GITHUB_PATH"
fi
PATH="$BIN_DIR:$PATH"
echo "==> Installed: $("$BIN_DIR/xcodegen" --version)"
fi