Compare commits

..
23 changed files with 13 additions and 1502 deletions
-57
View File
@@ -1,57 +0,0 @@
name: macOS CI
on:
push:
branches:
- '**'
pull_request:
branches:
- 'milestone/m6-gamut-stage0-cgats-release'
jobs:
build-and-test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch Argyll sidecars
run: scripts/fetch-argyll.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Generate Xcode project
run: xcodegen generate --project .
- name: Build and test (universal)
run: |
xcodebuild test \
-scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO
package:
needs: build-and-test
runs-on: self-hosted
if: github.ref == 'refs/heads/milestone/m6-gamut-stage0-cgats-release' || startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Package release
run: scripts/package-release.sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: iccery-dmg
path: ICCery-*.dmg
-6
View File
@@ -20,9 +20,3 @@ ICCery.xcodeproj/
# macOS
.DS_Store
# Release artefacts (not git blobs)
*.dmg
*.zip
Release/
notarization/
@@ -9,7 +9,6 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case printcalFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
@@ -31,8 +30,6 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .printcalFailed(let reason):
return "Calibration curve computation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
@@ -758,82 +755,6 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: processId)
}
}
// MARK: - Stage 0 calibration
/// Generates a calibration wedge `.ti1`.
public func runCalibrationTargen(
config: CalibrationTargenConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)"
let cleanBasename = try PathSecurity.sanitizeBasename(calBasename)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
///
/// `printcal` is captured (not streamed) and is exempt from the `-u`
/// JSON policy.
public func runPrintcal(
config: PrintcalConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let args = try PrintcalArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let binaryURL = binaryResolver.resolve("printcal")
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId)
let result = try await processManager.runCaptured(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
if let onLogBatch = onLogBatch, !result.stdout.isEmpty {
onLogBatch(result.stdout.components(separatedBy: .newlines))
}
guard result.exitCode == 0 else {
throw ArgyllRunnerError.printcalFailed(
result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)"
: result.stderr
)
}
let calURL = config.outputURL
guard FileManager.default.fileExists(atPath: calURL.path) else {
throw ArgyllRunnerError.missingArtefact(calURL.path)
}
return calURL
}
}
/// Events emitted by a running `chartread` session.
@@ -15,9 +15,8 @@ public enum AppPaths {
/// `~/Library/Application Support/com.gronod.iccery2`
///
/// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app
/// data so UI tests run against an isolated root and never touch the
/// developer's state.
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
/// against an isolated root and never touch the developer's state.
public static var appDataDir: URL {
#if DEBUG
if let root = testRoot {
@@ -43,32 +42,11 @@ public enum AppPaths {
}
#if DEBUG
/// DEBUG-only root override. Order:
/// 1. `ICCERY_TEST_ROOT` for an explicit test root.
/// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to
/// the current UI test's working directory.
/// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test
/// that sets neither of the above still runs in isolation.
///
/// Computed from `ProcessInfo` each call no mutable static state.
private static var testRoot: URL? {
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
!raw.isEmpty {
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
!raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw, isDirectory: true)
}
if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"],
!raw.isEmpty {
return URL(fileURLWithPath: raw, isDirectory: true)
}
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
return FileManager.default.temporaryDirectory
.appendingPathComponent(
"iccery-ui-\(ProcessInfo.processInfo.processIdentifier)",
isDirectory: true
)
}
return nil
}
#endif
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
@@ -1,203 +0,0 @@
import Foundation
/// A single channel's calibration curve.
public struct CalibrationCurve: Sendable, Equatable {
public let channel: Character
public let input: [Double]
public let output: [Double]
public init(channel: Character, input: [Double], output: [Double]) {
self.channel = channel
self.input = input
self.output = output
}
}
/// Parsed Argyll `.cal` curve data.
public struct CalibrationData: Sendable, Equatable {
public var colorRep: String
public var descriptor: String?
public var created: Date?
public var maxTac: Double?
public var inkLimits: [Character: Double]
public var curves: [CalibrationCurve]
public init(
colorRep: String = "",
descriptor: String? = nil,
created: Date? = nil,
maxTac: Double? = nil,
inkLimits: [Character: Double] = [:],
curves: [CalibrationCurve] = []
) {
self.colorRep = colorRep
self.descriptor = descriptor
self.created = created
self.maxTac = maxTac
self.inkLimits = inkLimits
self.curves = curves
}
}
/// Errors from loading and parsing a `.cal` file.
public enum CalibrationStoreError: Error, Equatable {
case unreadableFile
case missingColorRep
case missingCurveData
case unsupportedFormat
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .unreadableFile:
return "Could not read the calibration file."
case .missingColorRep:
return "The .cal file is missing its COLOR_REP header."
case .missingCurveData:
return "The .cal file contains no calibration curve data."
case .unsupportedFormat:
return "The .cal file format is not supported."
case .parseFailed(let reason):
return "Calibration parse failed: \(reason)"
}
}
}
/// Store for a calibration curve, its metadata, and staleness checks.
public actor CalibrationStore {
public private(set) var data: CalibrationData?
public private(set) var sourceURL: URL?
public private(set) var storedPrinterName: String?
/// Number of days after which a calibration is considered stale.
public var staleDays: Int
public init(staleDays: Int = 30) {
self.staleDays = staleDays
}
/// Load and parse a `.cal` file.
public func load(url: URL) async throws {
let dataset = try CGATSParser.parse(url: url)
guard let colorRep = dataset.colorRep, !colorRep.isEmpty else {
throw CalibrationStoreError.missingColorRep
}
var data = CalibrationData()
data.colorRep = colorRep
data.descriptor = dataset.keywords["DESCRIPTOR"]
if let createdString = dataset.keywords["CREATED"] {
let formatter = ISO8601DateFormatter()
data.created = formatter.date(from: createdString)
?? Date(timeIntervalSince1970: 0)
} else {
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
data.created = attrs?[.modificationDate] as? Date
}
let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"]
for key in limitKeys {
if let raw = dataset.keywords[key], let value = Double(raw) {
data.maxTac = value
break
}
}
for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") {
let suffix = key.dropFirst("INK_LIMIT_".count)
guard let channel = suffix.first, let value = Double(raw) else { continue }
data.inkLimits[channel] = value
}
data.curves = try Self.extractCurves(from: dataset)
guard !data.curves.isEmpty else {
throw CalibrationStoreError.missingCurveData
}
self.data = data
self.sourceURL = url
// Printer name may live in a sidecar JSON. For now, fall back to the
// descriptor so callers have something to compare.
self.storedPrinterName = data.descriptor
}
/// Store an explicit printer name (e.g. from a sidecar).
public func setPrinterName(_ name: String?) {
self.storedPrinterName = name
}
/// True if the loaded calibration is older than `staleDays` or the
/// printer name does not match.
public func isStale(comparedTo currentPrinter: String? = nil) -> Bool {
guard let data else { return true }
if let created = data.created,
let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created),
Date() > threshold {
return true
}
if let stored = storedPrinterName, !stored.isEmpty,
let current = currentPrinter, !current.isEmpty,
stored != current {
return true
}
return false
}
// MARK: - Internals
private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] {
// Argyll .cal files contain an INPUT_VALUE column and one or more
// per-channel output columns. Field names vary by COLOR_REP.
let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" }
guard !outputFields.isEmpty else {
// Older .cal files may only have one output column named OUTPUT_VALUE.
if dataset.fieldNames.contains("OUTPUT_VALUE") {
return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)]
}
throw CalibrationStoreError.missingCurveData
}
var curves = [CalibrationCurve]()
for field in outputFields {
let channel = field.first ?? "?"
let curve = try buildCurve(channel: channel, field: field, dataset: dataset)
curves.append(curve)
}
return curves
}
private static func buildCurve(
channel: Character,
field: String,
dataset: CGATSDataset
) throws -> CalibrationCurve {
var input = [Double]()
var output = [Double]()
for sample in dataset.samples {
guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field],
let inVal = parseNumber(inRaw),
let outRaw = sample.values[field],
let outVal = parseNumber(outRaw) else {
throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)")
}
input.append(inVal)
output.append(outVal)
}
return CalibrationCurve(channel: channel, input: input, output: output)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
}
@@ -1,93 +0,0 @@
import Foundation
/// Errors during calibration `targen` argv construction.
public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidSteps(Int)
case invalidInkLimit(Int)
case invalidWhitePatches(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid calibration basename: \(name)"
case .invalidSteps(let steps):
return "Calibration steps must be 1151, got: \(steps)"
case .invalidInkLimit(let limit):
return "Calibration ink limit must be 200400, got: \(limit)"
case .invalidWhitePatches(let count):
return "Calibration white patches cannot be negative, got: \(count)"
}
}
}
/// Configuration for a calibration wedge `targen` run.
public struct CalibrationTargenConfig: Sendable, Equatable {
public var colourSpace: ColourSpace
public var steps: Int
public var whitePatches: Int
public var includeNeutralEmphasis: Bool
public var inkLimit: Int?
public var basename: String
public var workingDirectory: URL?
public init(
colourSpace: ColourSpace = .rgb,
steps: Int = 21,
whitePatches: Int = 4,
includeNeutralEmphasis: Bool = false,
inkLimit: Int? = nil,
basename: String = "",
workingDirectory: URL? = nil
) {
self.colourSpace = colourSpace
self.steps = steps
self.whitePatches = whitePatches
self.includeNeutralEmphasis = includeNeutralEmphasis
self.inkLimit = inkLimit
self.basename = basename
self.workingDirectory = workingDirectory
}
}
/// Pure argv builder for the Stage 0 calibration `targen` chart.
///
/// Produces a per-channel wedge with `-f 0` (no full-spread patches).
public enum CalibrationTargenArgs {
/// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`.
public static func build(config: CalibrationTargenConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard (11...51).contains(config.steps) else {
throw CalibrationTargenArgError.invalidSteps(config.steps)
}
guard config.whitePatches >= 0 else {
throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches)
}
var args: [String] = [
"-v",
"-d", config.colourSpace.dFlagValue,
"-s", "\(config.steps)",
"-g", "\(config.steps)",
"-e", "\(config.whitePatches)",
"-f", "0"
]
if config.includeNeutralEmphasis {
args.append(contentsOf: ["-n", "\(config.steps)"])
}
if config.colourSpace == .cmyk, let inkLimit = config.inkLimit {
guard (200...400).contains(inkLimit) else {
throw CalibrationTargenArgError.invalidInkLimit(inkLimit)
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
}
@@ -1,114 +0,0 @@
import Foundation
/// Errors during `printcal` argv construction.
public enum PrintcalArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidTotalInkLimit(Double)
case invalidPerChannelLimit(Character, Double)
case invalidOutputPath
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid calibration basename: \(name)"
case .invalidTotalInkLimit(let limit):
return "Total ink limit must be positive, got: \(limit)"
case .invalidPerChannelLimit(let channel, let limit):
return "\(channel) channel limit must be 0100, got: \(limit)"
case .invalidOutputPath:
return "Invalid .cal output path"
}
}
}
/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`.
public struct PrintcalChannelLimit: Sendable, Equatable {
public let channel: Character
public let percent: Double
public init(channel: Character, percent: Double) {
self.channel = channel
self.percent = percent
}
}
/// Configuration for an Argyll `printcal` run.
public struct PrintcalConfig: Sendable, Equatable {
public var ti3Basename: String
public var workingDirectory: URL?
public var outputURL: URL
public var noInkLimit: Bool
public var verify: Bool
public var previousCalPath: String?
public var totalInkLimit: Double?
public var channelLimits: [PrintcalChannelLimit]
public init(
ti3Basename: String,
workingDirectory: URL? = nil,
outputURL: URL,
noInkLimit: Bool = false,
verify: Bool = false,
previousCalPath: String? = nil,
totalInkLimit: Double? = nil,
channelLimits: [PrintcalChannelLimit] = []
) {
self.ti3Basename = ti3Basename
self.workingDirectory = workingDirectory
self.outputURL = outputURL
self.noInkLimit = noInkLimit
self.verify = verify
self.previousCalPath = previousCalPath
self.totalInkLimit = totalInkLimit
self.channelLimits = channelLimits
}
}
/// Pure argv builder for Argyll's `printcal` tool.
///
/// `printcal` is captured, not streamed. JS never sends `-u` (unapply).
public enum PrintcalArgs {
/// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC]
/// [-xC pct]... -o out.cal CAL_basename`.
public static func build(config: PrintcalConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename)
guard !cleanBasename.isEmpty else {
throw PrintcalArgError.invalidBasename(config.ti3Basename)
}
var args: [String] = ["-v", "-e"]
if config.noInkLimit {
args.append("-I")
}
if config.verify {
args.append("-z")
}
if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!previous.isEmpty {
args.append(contentsOf: ["-a", previous])
}
if let tac = config.totalInkLimit, tac > 0 {
args.append(contentsOf: ["-m", String(format: "%.1f", tac)])
} else if let tac = config.totalInkLimit {
throw PrintcalArgError.invalidTotalInkLimit(tac)
}
for limit in config.channelLimits {
guard (0...100).contains(limit.percent) else {
throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent)
}
args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)])
}
guard !config.outputURL.path.isEmpty else {
throw PrintcalArgError.invalidOutputPath
}
args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
}
-111
View File
@@ -1,111 +0,0 @@
import SwiftUI
import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@Bindable var model: CalibrationViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Calibrate Printer")
.font(.title2.bold())
.padding(.horizontal, 16)
.padding(.top, 16)
Form {
Section("Wedge Settings") {
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
HStack {
Text("Steps per channel")
Spacer()
TextField("", value: $model.steps, format: .number)
.frame(width: 60)
.accessibilityIdentifier("calSteps")
}
HStack {
Text("White patches")
Spacer()
TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60)
}
if model.colourSpace == .cmyk {
HStack {
Text("Ink-limit exploration")
Spacer()
TextField("", text: $model.inkLimit)
.frame(width: 60)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
}
Section("Workflow") {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(!model.canGenerate)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(!model.canGenerate)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.calibrationLog.isEmpty {
Section("Log") {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.calibrationLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
}
}
}
.frame(minHeight: 80, maxHeight: 120)
}
}
if let error = model.lastError {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Return to Profiling") { model.returnToProfiling() }
.accessibilityIdentifier("btnCalReturn")
}
.padding(16)
}
}
}
-209
View File
@@ -1,209 +0,0 @@
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 {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
let environment: AppEnvironment
// 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
var lastError: String?
private var originalBasename: String = ""
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.profile = profile
self.environment = environment
}
private var wizard: WizardViewModel { workflow.wizard }
// MARK: - Derived
var canGenerate: Bool {
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating
}
var canCompute: Bool {
calibrationTi3URL != nil && !isComputing
}
var calibrationTi3URL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).ti3")
}
private var calBasename: String {
originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)"
}
private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal")
}
// MARK: - Generate calibration target
func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
originalBasename = wizard.basename
wizard.basename = calBasename
wizard.sessionMode = .calibration
isGenerating = true
calibrationLog = []
lastError = nil
let config = CalibrationTargenConfig(
colourSpace: colourSpace,
steps: steps,
whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue,
basename: originalBasename,
workingDirectory: cwd
)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isGenerating = false }
do {
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration target failed: \(error.localizedDescription)",
kind: .error
)
self.restoreProfileBasename()
}
}
}
// MARK: - Layout, print, measure
/// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename.
/// After measurement, the user returns and presses Compute Curves.
func createLayout() {
wizard.sessionMode = .calibration
wizard.go(to: .layOutPrint)
}
func measureChart() {
wizard.sessionMode = .calibration
wizard.go(to: .measure)
}
// MARK: - Compute curves
func computeCurves() {
guard canCompute,
let cwd = wizard.effectiveWorkingDirectory,
let outputURL = calOutputURL else { return }
// Collision check: the Argyll `printcal` exit error contains
// "already exists" when the user declines overwrite. We do not
// silently clobber.
if FileManager.default.fileExists(atPath: outputURL.path) {
lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first."
wizard.showNotice(lastError!, kind: .error)
return
}
isComputing = true
calibrationLog = []
lastError = nil
let config = PrintcalConfig(
ti3Basename: calBasename,
workingDirectory: cwd,
outputURL: outputURL,
noInkLimit: false,
verify: false,
previousCalPath: nil,
totalInkLimit: inkLimitValue.map { Double($0) },
channelLimits: []
)
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isComputing = false }
do {
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.computedCalURL = url
self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
self.wizard.showNotice("Calibration curves computed.")
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Calibration curve computation failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - Apply toggle
func updateApplyToProfile() {
profile.applyCalibration = applyToProfile
if applyToProfile, let url = computedCalURL {
profile.calibrationFile = url.path
} else if applyToProfile {
// User toggled on before computing; keep the path if already set.
} else {
profile.applyCalibration = false
}
}
func returnToProfiling() {
restoreProfileBasename()
wizard.sessionMode = .profile
wizard.go(to: .generate)
}
private func restoreProfileBasename() {
if !originalBasename.isEmpty {
wizard.basename = originalBasename
originalBasename = ""
}
}
private var inkLimitValue: Int? {
guard colourSpace == .cmyk else { return nil }
return Int(inkLimit)
}
}
+1 -3
View File
@@ -74,9 +74,7 @@ struct RootView: View {
Stage4View(model: workflow.profile)
case .verifyInstall:
Stage5View(model: workflow.profile)
case .calibrate:
CalibrationView(model: workflow.calibration)
@unknown default:
default:
StagePlaceholderView(stage: model.stage)
}
}
+3 -2
View File
@@ -74,13 +74,14 @@ struct SidebarView: View {
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Calibrate Printer (`#btnCalibratePrinter`).
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.accessibilityIdentifier("btnCalibratePrinter")
.disabled(true)
.padding(.horizontal, 12)
Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) {
-1
View File
@@ -85,7 +85,6 @@ struct Stage1View: View {
Button("Browse…") { workflow.browseForTargetFile() }
.accessibilityIdentifier("btnBrowse")
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
.accessibilityIdentifier("btnSelectWorkDir")
Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { workflow.importMeasurementDataset() }
+1 -11
View File
@@ -123,10 +123,8 @@ final class TargetWorkflowViewModel {
/// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel
/// 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 apply preset values.
var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow.
var calibration: CalibrationViewModel!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -139,12 +137,6 @@ final class TargetWorkflowViewModel {
wizard: wizard,
environment: environment
)
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
profile: self.profile,
environment: environment
)
reloadPresets()
}
@@ -347,8 +339,6 @@ final class TargetWorkflowViewModel {
customLabel: labelIsCustom ? customLabel : nil,
basename: wizard.basename,
metadata: labelMetadata),
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil,
calibrationEmbedOnly: false,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
@@ -1,81 +0,0 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
private func makeRunner() -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
return ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
}
private func makeTestDir() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("calibration-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
basename: "demo",
workingDirectory: testRoot
)
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
workingDirectory: testRoot,
outputURL: output
)
let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws printcalFailed")
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
workingDirectory: testRoot,
outputURL: output
)
setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1)
defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") }
await #expect(throws: (any Error).self) {
_ = try await runner.runPrintcal(config: config)
}
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -1,69 +0,0 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CalibrationStore")
struct CalibrationStoreTests {
private static let sampleCal = """
CTI3
DESCRIPTOR "Test printer"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
MAX_TAC "300"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 30)
try await store.load(url: url)
let data = await store.data
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255])
}
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true)
}
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
let store = CalibrationStore(staleDays: 9999)
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true)
}
}
@@ -1,62 +0,0 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
@Test("RGB baseline")
func rgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
whitePatches: 4,
basename: "demo",
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
}
@Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
whitePatches: 4,
includeNeutralEmphasis: true,
inkLimit: 320,
basename: "printer",
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
}
@Test("Rejects out-of-range steps")
func rejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
}
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test")
}
}
@@ -1,59 +0,0 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("PrintcalArgs")
struct PrintcalArgsTests {
private let tmp = URL(fileURLWithPath: "/tmp/out.cal")
@Test("Default printcal argv")
func defaults() throws {
let config = PrintcalConfig(
ti3Basename: "CAL_demo",
outputURL: tmp
)
let args = try PrintcalArgs.build(config: config)
#expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"])
}
@Test("All options and channel limits")
func allOptions() throws {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
noInkLimit: true,
verify: true,
previousCalPath: "/tmp/old.cal",
totalInkLimit: 280,
channelLimits: [
PrintcalChannelLimit(channel: "C", percent: 95),
PrintcalChannelLimit(channel: "M", percent: 90)
]
)
let args = try PrintcalArgs.build(config: config)
#expect(args == [
"-v", "-e",
"-I", "-z",
"-a", "/tmp/old.cal",
"-m", "280.0",
"-xC", "95.0",
"-xM", "90.0",
"-o", "/tmp/out.cal",
"CAL_demo"
])
}
@Test("Rejects invalid per-channel limit")
func rejectsBadChannelLimit() {
let config = PrintcalConfig(
ti3Basename: "demo",
outputURL: tmp,
channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)]
)
#expect(throws: (any Error).self) {
_ = try PrintcalArgs.build(config: config)
}
}
}
-25
View File
@@ -1,25 +0,0 @@
#!/bin/sh
# Mock printcal for Stage 0 calibration tests. Creates the .cal named by
# the -o argument in the process working directory. Exit code overridable
# via ICCERY_MOCK_PRINTCAL_EXIT.
output=""
basename=""
prev=""
for arg in "$@"; do
if [ "$prev" = "-o" ]; then
output="$arg"
fi
prev="$arg"
done
# If no -o, derive from the last positional argument.
if [ -z "$output" ]; then
for arg in "$@"; do basename="$arg"; done
output="$basename.cal"
fi
if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then
echo "mock printcal failure" >&2
exit "$ICCERY_MOCK_PRINTCAL_EXIT"
fi
echo "ideal power 1.0, device power 0.8"
touch "$output"
exit 0
@@ -44,10 +44,7 @@ final class Milestone6CGATSUITests: XCTestCase {
/// Must fail if import presents a save panel or a `.ti1` filter.
func testImportUsesOpenPanelNotSaveTi1() throws {
app.launch()
// CGATS import needs a working directory; the env provides one.
XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5))
app.buttons["btnSelectWorkDir"].tap()
app.activate()
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
app.buttons["btn-import-dataset"].click()
@@ -57,7 +54,8 @@ final class Milestone6CGATSUITests: XCTestCase {
XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.")
// The dataset should be accepted and the user should advance to Stage 4.
XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10))
_ = app.otherElements["stage-4"].waitForExistence(timeout: 10)
XCTAssertTrue(app.otherElements["stage-4"].exists)
// The canonical .ti3 should be written next to the source file.
let ti3URL = testRoot.appendingPathComponent("imported.ti3")
@@ -1,80 +0,0 @@
import Foundation
import XCTest
/// Milestone 6 Stage 0 printer calibration UI acceptance.
///
/// Uses the mock Argyll fixtures and UI-test environment flags so no real
/// instrument, printer, or modal file panel is required.
@MainActor
final class Milestone6CalibrationUITests: XCTestCase {
private var app: XCUIApplication!
private var testWorkDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testWorkDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cal-ui-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: testWorkDir,
withIntermediateDirectories: true
)
let binaryDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_ARGYLL_BINARY_DIR": binaryDir.path,
"ICCERY_TEST_WORKDIR": testWorkDir.path
]
app.launch()
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testWorkDir {
try? FileManager.default.removeItem(at: testWorkDir)
}
}
func testCalibrationDashboardOpensAndCanGenerate() throws {
// Set up a target and working directory on Stage 1.
let basename = app.textFields["targetBasename"]
XCTAssertTrue(basename.waitForExistence(timeout: 5))
basename.tap()
basename.typeText("DemoTarget")
let workDir = app.buttons["btnSelectWorkDir"]
XCTAssertTrue(workDir.waitForExistence(timeout: 5))
workDir.tap()
let generate = app.buttons["btnGenerate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5))
generate.tap()
// Open the calibration dashboard once Stage 2 is reached.
let advance = app.buttons["btnAdvanceToStage3"]
XCTAssertTrue(advance.waitForExistence(timeout: 10))
let calButton = app.buttons["btnCalibratePrinter"]
XCTAssertTrue(calButton.waitForExistence(timeout: 5))
calButton.tap()
XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5))
// Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1.
let calGenerate = app.buttons["btnCalGenerate"]
XCTAssertTrue(calGenerate.waitForExistence(timeout: 5))
calGenerate.tap()
// 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: 10))
}
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env python3
# scripts/dmgbuild-settings.py
#
# dmgbuild settings for ICCery. Set DMG_FILENAME and DMG_VOLUME_NAME in the
# environment, or accept the defaults. Background art can be supplied later by
# placing a PNG at Resources/dmg-background.png and setting DMG_BACKGROUND.
import os
filename = os.environ.get('DMG_FILENAME', 'ICCery.dmg')
volume_name = os.environ.get('DMG_VOLUME_NAME', 'ICCery')
# Background art is optional. If the referenced PNG does not exist, fall back
# to a plain window. See docs/23-assets.md for the DMG background spec.
background = os.environ.get('DMG_BACKGROUND', 'Resources/dmg-background.png')
if background and not os.path.exists(background):
background = None
icon = None
# Window size is enough for the app icon and the Applications alias.
window_rect = ((100, 100), (640, 480))
# Use icon view without extra chrome.
default_view = 'icon-view'
show_status_bar = False
show_tab_view = False
show_toolbar = False
show_pathbar = False
show_sidebar = False
sidebar_width = 180
# Position the .app on the left and the Applications alias on the right.
icon_locations = {
'ICCery.app': (140, 240),
'Applications': (500, 240),
}
# Symlink to /Applications for drag-and-drop install.
symlinks = {'Applications': '/Applications'}
-120
View File
@@ -1,120 +0,0 @@
#!/bin/sh
# scripts/package-release.sh
#
# Release packaging pipeline for ICCery v2 macOS.
#
# Steps:
# 1. Fetch and ad-hoc sign Argyll sidecars (scripts/fetch-argyll.sh).
# 2. Generate the Xcode project from project.yml.
# 3. Build a universal Release ICCery.app.
# 4. Sign the .app (Developer ID if CODESIGN_IDENTITY is set, else ad-hoc).
# 5. Hard-fail verify every bundled Mach-O sidecar with codesign -dvv.
# 6. Build a DMG with dmgbuild.
# 7. Optionally notarize and staple the DMG when notarization secrets exist.
#
# Required secrets (optional):
# CODESIGN_IDENTITY Developer ID Application identity name
# DEVELOPMENT_TEAM Apple development team ID (for xcodebuild signing)
# NOTARIZE_APPLE_ID Apple ID for notarytool
# NOTARIZE_PASSWORD App-specific password for notarytool
# APPLE_TEAM_ID Team ID for notarytool
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT"
echo "==> Fetching Argyll sidecars"
scripts/fetch-argyll.sh
echo "==> Generating Xcode project"
xcodegen generate --project .
CONFIG="Release"
DEST="platform=macOS"
# Default to ad-hoc signing. A real Developer ID can be injected via env.
IDENTITY="${CODESIGN_IDENTITY:--}"
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
echo "==> Building universal Release app"
BUILD_EXTRA=""
if [ -n "$DEVELOPMENT_TEAM" ]; then
BUILD_EXTRA="DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM"
fi
# shellcheck disable=SC2086
xcodebuild \
-scheme ICCery \
-destination "$DEST" \
-configuration "$CONFIG" \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY="$IDENTITY" \
$BUILD_EXTRA \
build
echo "==> Locating built app"
BUILT_PRODUCTS_DIR="$(xcodebuild \
-scheme ICCery \
-destination "$DEST" \
-configuration "$CONFIG" \
-showBuildSettings \
| sed -n 's/^ *BUILT_PRODUCTS_DIR = //p' \
| head -n 1)"
APP="$BUILT_PRODUCTS_DIR/ICCery.app"
if [ ! -d "$APP" ]; then
echo "error: built app not found at $APP" >&2
exit 1
fi
echo "App: $APP"
# If a Developer ID identity was supplied, re-sign the .app bundle. Sidecars
# live in Resources/Argyll and remain ad-hoc signed by fetch-argyll.sh.
if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ]; then
echo "==> Signing $APP with '$CODESIGN_IDENTITY'"
codesign --force --sign "$CODESIGN_IDENTITY" \
--entitlements Resources/ICCery.entitlements \
--options runtime \
"$APP"
else
echo "==> App ad-hoc signed by xcodebuild; not re-signing"
fi
echo "==> Verifying sidecar signatures"
scripts/verify-sidecar-signatures.sh "$APP"
echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || echo '2.0.0')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || echo '1')"
DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg"
VOLUME_NAME="ICCery ${VERSION}"
if ! command -v dmgbuild >/dev/null 2>&1; then
echo "==> Installing dmgbuild"
pip3 install dmgbuild
fi
DMG_FILENAME="$DMG" \
DMG_VOLUME_NAME="$VOLUME_NAME" \
dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
echo "DMG: $PWD/$DMG"
# Optional notarization/stapling when credentials are present.
if [ -n "${NOTARIZE_APPLE_ID:-}" ] && \
[ -n "${NOTARIZE_PASSWORD:-}" ] && \
[ -n "${APPLE_TEAM_ID:-}" ]; then
echo "==> Submitting $DMG for notarization"
xcrun notarytool submit "$DMG" \
--apple-id "$NOTARIZE_APPLE_ID" \
--password "$NOTARIZE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$DMG"
echo "==> Stapled $DMG"
else
echo "==> Notarization credentials not set; skipping"
fi
-45
View File
@@ -1,45 +0,0 @@
#!/bin/sh
# scripts/verify-sidecar-signatures.sh
#
# Hard-fail check that every Mach-O Argyll sidecar shipped inside the built
# ICCery.app bundle is signed (ad-hoc or Developer ID). Run this in CI after
# xcodebuild and before packaging.
#
# Usage: scripts/verify-sidecar-signatures.sh <path/to/ICCery.app>
set -eu
APP="${1:-}"
if [ -z "$APP" ]; then
echo "usage: $0 <path/to/ICCery.app>" >&2
exit 2
fi
if [ ! -d "$APP" ]; then
echo "error: app bundle not found: $APP" >&2
exit 1
fi
SIDECAR_DIR="$APP/Contents/Resources/Argyll"
if [ ! -d "$SIDECAR_DIR" ]; then
echo "error: Argyll sidecar directory not found: $SIDECAR_DIR" >&2
exit 1
fi
UNSIGNED=""
for f in "$SIDECAR_DIR"/*; do
[ -f "$f" ] || continue
if file -b "$f" | grep -q 'Mach-O'; then
if ! codesign -dvv "$f" >/dev/null 2>&1; then
echo "error: unsigned Mach-O sidecar: $f" >&2
UNSIGNED="$UNSIGNED $f"
fi
fi
done
if [ -n "$UNSIGNED" ]; then
echo "error: unsigned Argyll sidecars remain:$UNSIGNED" >&2
exit 1
fi
echo "OK: all Mach-O sidecars in $SIDECAR_DIR are signed"