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