Compare commits

...
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 17ee5d6717 test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)
MainActor.assertIsolated() requires Swift 5.9 (Xcode 15); the macOS 12
CI runner compiles the test bundle with Swift 5.7 and failed run 31985.
Assert Thread.isMainThread instead — it checks the same contract (the
coalesced log sink hops back to the main actor).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:06:45 +01:00
gronod 2295bcdbae Merge pull request 'fix(m9): clear printTask without defer for Swift 5.7 (#113)' (#114) from feat/113-printtask-defer-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m59s
macOS CI / package (push) Skipped
Reviewed-on: #114
2026-09-12 09:01:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c9bff50a1a fix(m9): clear printTask without defer for Swift 5.7 (#113)
Swift 5.7 on the macOS 12 CI runner (Xcode 14.2) rejects mutation of
actor-isolated properties inside defer bodies, failing run 31978 at
PrintSessionViewModel.swift:113 and :144. Clear printTask explicitly at
each exit point of the print task closures.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:00:48 +01:00
gronod 1b3d3dd821 Merge pull request 'fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)' (#112) from feat/111-stage4-viewbuilder-limit into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m28s
macOS CI / package (push) Skipped
Reviewed-on: #112
2026-09-12 08:55:57 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55dee0f0cd fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)
The formSection VStack had 11 direct children; Xcode 14.2's ViewBuilder
only provides buildBlock overloads up to 10 arguments, so the CI build
on the macOS 12 runner failed at line 144 with "extra argument in call"
(run 31958). Nest the calibration toggle and conditional file row in an
inner VStack with identical alignment and spacing.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:55:12 +01:00
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
25 changed files with 397 additions and 225 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) {
@@ -111,7 +110,8 @@ final class PrintSessionViewModel {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.printTask = nil } // `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
var printed = 0 var printed = 0
for page in result.pages { for page in result.pages {
do { do {
@@ -124,6 +124,7 @@ final class PrintSessionViewModel {
+ error.localizedDescription + error.localizedDescription
) )
isPrinting = false isPrinting = false
self.printTask = nil
return return
} }
} }
@@ -133,6 +134,7 @@ final class PrintSessionViewModel {
autoHideAfter: nil autoHideAfter: nil
) )
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
@@ -142,7 +144,6 @@ final class PrintSessionViewModel {
isPrinting = true isPrinting = true
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
defer { self.printTask = nil }
do { do {
try await spool(page, index: page.index, pageSize: pageSize) try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice( printNotice = Notice(
@@ -157,6 +158,7 @@ final class PrintSessionViewModel {
) )
} }
isPrinting = false isPrinting = false
self.printTask = nil
} }
printTask = task printTask = task
} }
+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) {
+22 -10
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) {
@@ -130,16 +138,20 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright") .accessibilityIdentifier("colprofCopyright")
Toggle("Apply calibration curve", isOn: $model.applyCalibration) // Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
.accessibilityIdentifier("colprofApplyCalibration") // 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
VStack(alignment: .leading, spacing: 12) {
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration { if model.applyCalibration {
HStack { HStack {
TextField("Calibration .cal file", text: $model.calibrationFile) TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile") .accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() } Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile") .accessibilityIdentifier("btnBrowseCalibrationFile")
}
} }
} }
} }
+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
@@ -21,7 +21,9 @@ final class ProcessRunSupportTests: XCTestCase {
setRunning: { running.append($0) }, setRunning: { running.append($0) },
resetLog: { resets += 1 }, resetLog: { resets += 1 },
onLog: { batch in onLog: { batch in
MainActor.assertIsolated() // MainActor.assertIsolated() needs Swift 5.9; the runner is
// on Xcode 14.2 (Swift 5.7) (#115).
XCTAssertTrue(Thread.isMainThread)
received.append(contentsOf: batch) received.append(contentsOf: batch)
} }
) { onLog in ) { onLog in
@@ -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