Milestone 5 — Profile generation, verification & installation (#23–#27) #51

Merged
gronod merged 1 commits from feat/23-colprof into milestone/m5-profile 2026-09-09 11:36:36 +01:00
43 changed files with 3049 additions and 15 deletions
@@ -8,6 +8,11 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
case profcheckUnparseable
public var errorDescription: String? {
switch self {
@@ -23,6 +28,16 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
return "Gamut extraction failed: \(reason)"
case .profcheckFailed(let reason):
return "Profile verification failed: \(reason)"
case .profcheckUnparseable:
return "Profile verification produced unparseable output"
}
}
}
@@ -157,6 +172,7 @@ public struct ArgyllRunner: Sendable {
private struct CollectedRun {
var exitCode: Int32?
var stdout: String
var stderr: String
var lines: [String]
}
@@ -170,6 +186,7 @@ public struct ArgyllRunner: Sendable {
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
var stderr = ""
var pendingBatch: [String] = []
var exitCode: Int32?
var lastFlush = Date()
@@ -190,6 +207,7 @@ public struct ArgyllRunner: Sendable {
pendingBatch.append(line)
case .stderr(_, let line):
lines.append(line)
stderr += line + "\n"
pendingBatch.append(line)
case .error(_, let message):
lines.append("Error: \(message)")
@@ -211,7 +229,7 @@ public struct ArgyllRunner: Sendable {
break
}
}
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
}
// MARK: - instlist (Stage 3 detection)
@@ -303,6 +321,199 @@ public struct ArgyllRunner: Sendable {
return canonical
}
// MARK: - colprof (Stage 4)
/// Runs `colprof` streaming, collecting logs and classifying progress
/// until the profile is written.
public func runColprof(
config: ColprofConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename)
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.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
guard let profileURL = ArtefactProbe.resolveProfile(
basename: cleanBasename,
cwd: cwd
) else {
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
}
return profileURL
}
// MARK: - applycal (post-colprof calibration curve)
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
///
/// Runs `applycal` captured and performs an in-place replace via
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
/// file is removed and the original is left untouched.
public func runApplycal(
config: ApplycalConfig
) async throws -> URL {
let inputURL = config.inputProfileURL
let cwd = inputURL.deletingLastPathComponent()
let binaryURL = binaryResolver.resolve("applycal")
let processId = ProcessID.applycal(inputURL.lastPathComponent)
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
let fm = FileManager.default
// Remove any stale tmp from a previous crash.
try? fm.removeItem(at: tmpURL)
let outputConfig = ApplycalConfig(
calibrationPath: config.calibrationPath,
inputProfileURL: inputURL,
outputProfileURL: tmpURL,
unapply: false
)
let outputArgs = try ApplycalArgs.build(config: outputConfig)
let result = try await processManager.runCaptured(
id: processId,
binary: binaryURL,
arguments: outputArgs,
workingDirectory: cwd
)
guard result.exitCode == 0 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)"
: result.stderr
)
}
guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed(
"applycal did not create temp profile"
)
}
do {
if fm.fileExists(atPath: inputURL.path) {
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: inputURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
}
return inputURL
}
// MARK: - iccgamut (post-colprof gamut mesh)
/// Extracts a `.gam` mesh from the finished profile.
public func runIccgamut(
config: IccgamutConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let profileURL = config.profileURL
let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem)
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.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else {
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
}
// MARK: - profcheck (Stage 5 verification)
/// Verifies a profile against the canonical `.ti3`.
public func runProfcheck(
config: ProfcheckConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> ProfcheckReport {
let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path
let iccURL = Self.resolveProfileForVerification(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
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.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output)
guard report.isValid else {
throw ArgyllRunnerError.profcheckUnparseable
}
return report
}
private static func resolveProfileForVerification(_ url: URL) -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return url }
let alt = url.pathExtension.lowercased() == "icc"
? url.deletingPathExtension().appendingPathExtension("icm")
: url.deletingPathExtension().appendingPathExtension("icc")
return fm.fileExists(atPath: alt.path) ? alt : url
}
// MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events.
@@ -0,0 +1,50 @@
import Foundation
/// Errors during `applycal` argv construction.
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
case invalidCalibrationPath
case invalidInputProfileURL
public var errorDescription: String? {
switch self {
case .invalidCalibrationPath:
return "Calibration path is invalid or empty"
case .invalidInputProfileURL:
return "Input profile path is invalid or empty"
}
}
}
/// Pure argv builder for Argyll's `applycal` tool.
///
/// `applycal` is always run captured, never streamed.
public enum ApplycalArgs {
/// Builds `applycal -v -a {cal} {input} [{output}]`.
///
/// `-u` (unapply) is rejected at the builder level the UI never
/// sends it (docs/04 §7.2).
public static func build(config: ApplycalConfig) throws -> [String] {
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
let input = config.inputProfileURL.path
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
var args: [String] = ["-v"]
if config.unapply {
// Defensive: should never be called from the UI.
args.append("-u")
} else {
args.append("-a")
}
args.append(contentsOf: [cal, input])
if let output = config.outputProfileURL?.path, !output.isEmpty {
args.append(output)
}
return args
}
}
@@ -0,0 +1,22 @@
import Foundation
/// Configuration for an Argyll `applycal` run.
public struct ApplycalConfig: Sendable, Equatable {
public var calibrationPath: String
public var inputProfileURL: URL
public var outputProfileURL: URL?
public var unapply: Bool
/// In-place when `outputProfileURL` is `nil`.
public init(
calibrationPath: String,
inputProfileURL: URL,
outputProfileURL: URL? = nil,
unapply: Bool = false
) {
self.calibrationPath = calibrationPath
self.inputProfileURL = inputProfileURL
self.outputProfileURL = outputProfileURL
self.unapply = unapply
}
}
@@ -0,0 +1,79 @@
import Foundation
/// Errors during `colprof` argv construction.
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid colprof basename: \(name)"
}
}
}
/// Pure argv builder for Argyll's `colprof` tool.
public enum ColprofArgs {
/// Builds `colprof` argv per the Gronod fork protocol.
///
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
/// only when their fields are non-empty and meaningful. `-f` has
/// special handling for "none" (omit), "" (bare flag), and a custom
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
/// skipped when set to "none".
public static func build(config: ColprofConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality])
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() {
case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty {
args.append("-f")
}
default:
args.append(contentsOf: ["-f", fwa])
}
}
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
args.append(contentsOf: ["-i", illuminant])
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond])
}
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond])
}
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
if let description = profileDescription, !description.isEmpty {
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
public struct ColprofConfig: Sendable, Equatable {
public var algorithm: String
public var quality: String
public var intent: String?
public var fwa: String?
public var illuminant: String?
public var observer: String?
public var inputViewingCond: String?
public var outputViewingCond: String?
public var description: String?
public var copyright: String?
public var basename: String
public var workingDirectory: URL?
public init(
algorithm: String = "l",
quality: String = "m",
intent: String? = nil,
fwa: String? = nil,
illuminant: String? = nil,
observer: String? = nil,
inputViewingCond: String? = nil,
outputViewingCond: String? = nil,
description: String? = nil,
copyright: String? = nil,
basename: String,
workingDirectory: URL? = nil
) {
self.algorithm = algorithm
self.quality = quality
self.intent = intent
self.fwa = fwa
self.illuminant = illuminant
self.observer = observer
self.inputViewingCond = inputViewingCond
self.outputViewingCond = outputViewingCond
self.description = description
self.copyright = copyright
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Classified `colprof` stdout progress milestone.
public enum ColprofProgress: Sendable, Equatable {
case gamutMapping
case fittingClut
case writingIcc
case unknown
}
/// Parses `colprof` plaintext progress (docs/16 §6.3).
///
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
/// Progress is therefore inferred from case-insensitive substring matches.
public enum ColprofProgressClassifier {
public static func classify(line: String) -> ColprofProgress {
let lower = line.lowercased()
if lower.contains("gamut mapping") {
return .gamutMapping
}
if lower.contains("fitting") || lower.contains("clut") {
return .fittingClut
}
if lower.contains("writing") || lower.contains("icc profile") {
return .writingIcc
}
return .unknown
}
}
@@ -0,0 +1,41 @@
import Foundation
/// Computes a consecutive-breach warning from verification history.
///
/// A drift alert triggers when there are at least two `poor` records on
/// distinct calendar days, or two `poor` records at least one hour apart.
public enum DriftAlert {
/// Returns an alert message, or `nil` when no consecutive breach exists.
public static func compute(from records: [VerificationRecord]) -> String? {
let poor = records
.filter { $0.status == .poor }
.sorted { $0.timestamp < $1.timestamp }
guard poor.count >= 2 else { return nil }
for i in 0..<poor.count {
for j in (i + 1)..<poor.count {
let a = poor[i]
let b = poor[j]
let sameDay = Calendar.utc.isDate(a.timestamp, inSameDayAs: b.timestamp)
let oneHour = b.timestamp.timeIntervalSince(a.timestamp) >= 3600
if !sameDay || oneHour {
return "Drift alert: poor results between \(a.id) and \(b.id)."
}
}
}
return nil
}
}
private extension Calendar {
static let utc: Calendar = {
var c = Calendar(identifier: .iso8601)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()
}
@@ -0,0 +1,29 @@
import Foundation
/// Errors during `iccgamut` argv construction.
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
case invalidProfileURL
public var errorDescription: String? {
switch self {
case .invalidProfileURL:
return "iccgamut requires a valid profile path"
}
}
}
/// Pure argv builder for Argyll's `iccgamut` tool.
public enum IccgamutArgs {
/// Builds `iccgamut -v -d {density} {profilePath}`.
///
/// The caller is responsible for ensuring `density` is a positive
/// integer. `-d` here is surface **density**, not a directory.
public static func build(config: IccgamutConfig) throws -> [String] {
let path = config.profileURL.path
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
let density = max(1, config.density)
return ["-v", "-d", "\(density)", path]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `iccgamut` run.
public struct IccgamutConfig: Sendable, Equatable {
public var profileURL: URL
public var density: Int
public init(profileURL: URL, density: Int = 10) {
self.profileURL = profileURL
self.density = density
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for a profile installation.
public struct InstallProfileConfig: Sendable, Equatable {
public var sourceURL: URL
public var options: InstallProfileOptions
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
self.sourceURL = sourceURL
self.options = options
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Collision policy for profile installation.
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
case overwrite
case rename
case cancel
}
/// Options for installing a finished profile into the OS colour store.
public struct InstallProfileOptions: Sendable, Equatable, Codable {
public var forceOverwrite: Bool
public var preferSystem: Bool
public var collisionPolicy: ProfileCollisionPolicy
public var openColorPanel: Bool
public var calibrationNote: String?
public init(
forceOverwrite: Bool = false,
preferSystem: Bool = false,
collisionPolicy: ProfileCollisionPolicy = .cancel,
openColorPanel: Bool = false,
calibrationNote: String? = nil
) {
self.forceOverwrite = forceOverwrite
self.preferSystem = preferSystem
self.collisionPolicy = collisionPolicy
self.openColorPanel = openColorPanel
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Result of installing a profile into the OS colour store.
public struct InstallProfileResult: Sendable, Equatable, Codable {
public var destPath: String
public var registered: Bool
public var overwritten: Bool
public var renamed: Bool
public var openedPanel: Bool
public var message: String
public var calibrationNote: String?
public init(
destPath: String,
registered: Bool,
overwritten: Bool,
renamed: Bool,
openedPanel: Bool,
message: String,
calibrationNote: String? = nil
) {
self.destPath = destPath
self.registered = registered
self.overwritten = overwritten
self.renamed = renamed
self.openedPanel = openedPanel
self.message = message
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Errors during `profcheck` argv construction.
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
case missingTi3
case missingIcc
case invalidTi3Path
public var errorDescription: String? {
switch self {
case .missingTi3:
return "profcheck requires a .ti3 file"
case .missingIcc:
return "profcheck requires a profile (.icc/.icm)"
case .invalidTi3Path:
return "profcheck .ti3 path is invalid"
}
}
}
/// Pure argv builder for Argyll's `profcheck` tool.
public enum ProfcheckArgs {
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
///
/// The `-u` here is the Gronod fork JSON report flag, not the
/// generic `-u` auto-fix that some Argyll builds use.
public static func build(config: ProfcheckConfig) throws -> [String] {
let ti3Path = config.ti3URL.path
let iccPath = config.iccURL.path
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `profcheck` run.
public struct ProfcheckConfig: Sendable, Equatable {
public var ti3URL: URL
public var iccURL: URL
public init(ti3URL: URL, iccURL: URL) {
self.ti3URL = ti3URL
self.iccURL = iccURL
}
}
@@ -0,0 +1,217 @@
import Foundation
/// Errors returned when `profcheck` output cannot be parsed.
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
case unparseable
case jsonDecodingFailed
public var errorDescription: String? {
switch self {
case .unparseable:
return "Could not parse profcheck report"
case .jsonDecodingFailed:
return "profcheck JSON report could not be decoded"
}
}
}
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
public enum ProfcheckParser {
/// Parsing order (issue #25):
/// 1. Patch count from `No of test patches = N`.
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
/// 4. Broad regex fallback.
/// 5. If no metrics found, return a report whose `warning` is set.
public static func parse(_ output: String) -> ProfcheckReport {
var report = ProfcheckReport()
// 1. Patch count.
let patchRegex = try? NSRegularExpression(
pattern: #"No of test patches\s*=\s*(\d+)"#,
options: [.caseInsensitive]
)
if let match = patchRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
), let range = Range(match.range(at: 1), in: output) {
let count = Int(output[range])
report.patchCount = count
}
// 2. JSON objects.
let jsonObjects = extractJSONObjects(from: output)
for object in jsonObjects {
if let event = object["event"] as? String, event == "report" {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
if hasMetricKeys(object) {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
}
// 3. Legacy text.
let textRegex = try? NSRegularExpression(
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
if let match = textRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
) {
let numbers = (1...3).compactMap { i -> Double? in
guard let range = Range(match.range(at: i), in: output) else { return nil }
return Double(output[range])
}
if numbers.count == 3 {
report.maxDE = numbers[0]
report.avgDE = numbers[1]
report.rmsDE = numbers[2]
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
return report
}
}
// 4. Broad regex fallback.
if let fallback = parseRegexFallback(output) {
var merged = fallback
merged.patchCount = report.patchCount
return merged
}
// 5. Unparseable.
report.warning = "profcheck output did not contain a recognisable report."
return report
}
// MARK: - JSON extraction
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
var objects: [[String: Any]] = []
var start: String.Index?
var depth = 0
for index in output.indices {
let char = output[index]
if char == "{" {
if depth == 0 {
start = index
}
depth += 1
} else if char == "}" {
if depth > 0 {
depth -= 1
if depth == 0, let start = start {
let jsonString = String(output[start...index])
if let data = jsonString.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
objects.append(object)
}
}
}
}
}
return objects
}
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
let keys = [
"avg_de", "avg_de2000",
"peak_de", "peak_de2000", "max_de",
"rms", "rms_de"
]
return keys.contains { object[$0] != nil }
}
private struct Metrics {
var avg: Double?
var max: Double?
var rms: Double?
}
private static func metrics(from object: [String: Any]) -> Metrics? {
var m = Metrics()
m.avg = doubleValue(for: "avg_de2000", in: object)
?? doubleValue(for: "avg_de", in: object)
m.max = doubleValue(for: "peak_de2000", in: object)
?? doubleValue(for: "peak_de", in: object)
?? doubleValue(for: "max_de", in: object)
?? doubleValue(for: "max_de2000", in: object)
m.rms = doubleValue(for: "rms", in: object)
?? doubleValue(for: "rms_de", in: object)
?? doubleValue(for: "rms_de2000", in: object)
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
return m
}
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
if let number = object[key] as? Double { return number }
if let number = object[key] as? NSNumber { return number.doubleValue }
if let string = object[key] as? String { return Double(string) }
return nil
}
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
report.avgDE = metrics.avg
report.maxDE = metrics.max
report.rmsDE = metrics.rms
if let avg = metrics.avg {
report.status = VerificationStatus.from(avgDE: avg)
}
}
// MARK: - Regex fallback
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
var report = ProfcheckReport()
let avgRegex = try? NSRegularExpression(
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let maxRegex = try? NSRegularExpression(
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let rmsRegex = try? NSRegularExpression(
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
report.avgDE = firstDouble(from: output, regex: avgRegex)
report.maxDE = firstDouble(from: output, regex: maxRegex)
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
return nil
}
if let avg = report.avgDE {
report.status = VerificationStatus.from(avgDE: avg)
}
return report
}
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
guard let regex = regex,
let match = regex.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
),
let range = Range(match.range(at: 1), in: output) else { return nil }
return Double(output[range])
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Parsed result from a `profcheck -u` run.
public struct ProfcheckReport: Sendable, Equatable, Codable {
public var patchCount: Int?
public var avgDE: Double?
public var maxDE: Double?
public var rmsDE: Double?
public var status: VerificationStatus?
public var warning: String?
public var isValid: Bool {
avgDE != nil && maxDE != nil && rmsDE != nil
}
public init(
patchCount: Int? = nil,
avgDE: Double? = nil,
maxDE: Double? = nil,
rmsDE: Double? = nil,
status: VerificationStatus? = nil,
warning: String? = nil
) {
self.patchCount = patchCount
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.status = status
self.warning = warning
}
}
@@ -0,0 +1,172 @@
import Foundation
/// Errors thrown by `ProfileInstaller`.
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
case unsafeStem(String)
case sourceMissing
case sourceNotProfile
case sourceTooSmall
case systemRequiresAdminRights
case copyFailed(String)
case cancelled
public var errorDescription: String? {
switch self {
case .unsafeStem(let stem):
return "Profile name contains unsafe characters: \(stem)"
case .sourceMissing:
return "Source profile does not exist"
case .sourceNotProfile:
return "Source must be a .icc or .icm file"
case .sourceTooSmall:
return "Source file is too small to be a valid profile"
case .systemRequiresAdminRights:
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
case .copyFailed(let reason):
return "Could not install profile: \(reason)"
case .cancelled:
return "Install cancelled"
}
}
}
/// Installs an ICC/ICM profile into the OS colour store.
public enum ProfileInstaller {
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
public static func install(config: InstallProfileConfig) throws -> InstallProfileResult {
let fm = FileManager.default
// Source validation.
let sourceURL = config.sourceURL
guard fm.fileExists(atPath: sourceURL.path) else {
throw ProfileInstallError.sourceMissing
}
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
throw ProfileInstallError.sourceTooSmall
}
// Stem security.
let stem = sourceURL.deletingPathExtension().lastPathComponent
guard !stem.contains("..") && !stem.contains("/") && !stem.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
// Destination directory.
let destDir: URL
if config.options.preferSystem {
destDir = URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
let home = fm.homeDirectoryForCurrentUser
destDir = home.appendingPathComponent("Library/ColorSync/Profiles")
}
// Ensure parent exists.
try? fm.createDirectory(at: destDir, withIntermediateDirectories: true)
let destURL = destDir.appendingPathComponent("\(stem).icc")
// Collision resolution.
let destExists = fm.fileExists(atPath: destURL.path)
if destExists {
if config.options.forceOverwrite {
// Continue to overwrite path.
} else if config.options.collisionPolicy == .rename {
let epoch = Int(Date().timeIntervalSince1970)
let renamedURL = destDir.appendingPathComponent("\(stem)-\(epoch).icc")
return try performInstall(
from: sourceURL,
to: renamedURL,
options: config.options,
overwritten: false,
renamed: true
)
} else if config.options.collisionPolicy == .cancel {
throw ProfileInstallError.cancelled
} else {
// Default with askBeforeOverwrite the app must decide.
throw ProfileInstallError.copyFailed("destination already exists")
}
}
return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
overwritten: destExists,
renamed: false
)
}
private static func performInstall(
from sourceURL: URL,
to destURL: URL,
options: InstallProfileOptions,
overwritten: Bool,
renamed: Bool
) throws -> InstallProfileResult {
let fm = FileManager.default
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
// Remove stale tmp.
try? fm.removeItem(at: tmpURL)
do {
try fm.copyItem(at: sourceURL, to: tmpURL)
if fm.fileExists(atPath: destURL.path) {
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
// Surface a clear admin-rights hint when writing to system.
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
throw ProfileInstallError.systemRequiresAdminRights
}
throw ProfileInstallError.copyFailed(error.localizedDescription)
}
let registered = fm.fileExists(atPath: destURL.path)
var openedPanel = false
if options.openColorPanel {
openedPanel = openColorSyncUtility()
}
return InstallProfileResult(
destPath: destURL.path,
registered: registered,
overwritten: overwritten,
renamed: renamed,
openedPanel: openedPanel,
message: "Profile installed to \(destURL.path)",
calibrationNote: options.calibrationNote
)
}
private static func openColorSyncUtility() -> Bool {
let task = Process()
task.launchPath = "/usr/bin/open"
task.arguments = ["-a", "ColorSync Utility"]
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
do {
try task.run()
task.waitUntilExit()
} catch {
return false
}
return true
}
}
@@ -0,0 +1,120 @@
import Foundation
/// Persistence for `VerificationRecord` entries.
public actor VerificationHistoryStore {
/// Default cap.
public static let defaultCapacity = 1000
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var records: [VerificationRecord] = []
private let capacity: Int
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
}
/// Loads records from disk. Returns the existing cache if already loaded.
///
/// Throws when the file exists but cannot be parsed; the existing file
/// is never overwritten in that case.
public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records
}
/// Returns all records.
public func all() -> [VerificationRecord] {
records
}
/// Records matching the optional printer filter.
public func filtered(by printer: String?) -> [VerificationRecord] {
guard let printer = printer, !printer.isEmpty else { return records }
return records.filter { $0.printerName == printer }
}
/// Appends a record, trims to capacity, and writes atomically.
///
/// Returns the trimmed list, or `nil` if a write error occurs so the
/// caller can surface the failure without replacing the in-memory list.
@discardableResult
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
var updated = records
updated.append(record)
if updated.count > capacity {
updated.sort { $0.timestamp < $1.timestamp }
updated = Array(updated.suffix(capacity))
}
try write(updated)
records = updated
return updated
}
/// Removes all history and updates disk.
public func clear() throws {
try write([])
records = []
}
/// RFC-4180 CSV export.
public func exportCSV() -> String {
var lines: [String] = [
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
]
for record in records {
lines.append(csvRow([
record.id,
record.profileName,
record.printerName,
String(record.avgDE),
String(record.maxDE),
String(record.rmsDE),
String(record.patchCount),
record.status.rawValue,
ISO8601DateFormatter().string(from: record.timestamp)
]))
}
return lines.joined(separator: "\n") + "\n"
}
/// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records)
try AtomicFileWriter.write(data, to: url)
}
private func csvRow(_ fields: [String]) -> String {
fields.map { field in
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
return "\"\(escaped)\""
}
return escaped
}.joined(separator: ",")
}
}
@@ -0,0 +1,36 @@
import Foundation
/// A single entry in the verification history store.
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
public var id: String
public var profileName: String
public var printerName: String
public var avgDE: Double
public var maxDE: Double
public var rmsDE: Double
public var patchCount: Int
public var status: VerificationStatus
public var timestamp: Date
public init(
id: String,
profileName: String,
printerName: String,
avgDE: Double,
maxDE: Double,
rmsDE: Double,
patchCount: Int,
status: VerificationStatus,
timestamp: Date
) {
self.id = id
self.profileName = profileName
self.printerName = printerName
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.patchCount = patchCount
self.status = status
self.timestamp = timestamp
}
}
@@ -0,0 +1,32 @@
import Foundation
/// ICCery quality band for a verification run.
///
/// Bands are on the **average** ΔE:
/// - < 1.0 Excellent
/// - < 2.0 Good
/// - < 3.5 Acceptable
/// - 3.5 Warning
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
case excellent
case good
case acceptable
case poor
public var displayName: String {
switch self {
case .excellent: return "Excellent"
case .good: return "Good"
case .acceptable: return "Acceptable"
case .poor: return "Warning"
}
}
/// Returns the quality band for the given average ΔE.
public static func from(avgDE: Double) -> VerificationStatus {
if avgDE < 1.0 { return .excellent }
if avgDE < 2.0 { return .good }
if avgDE < 3.5 { return .acceptable }
return .poor
}
}
+3 -1
View File
@@ -12,6 +12,7 @@ struct AppEnvironment: Sendable {
let presetStore: PresetStore
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -38,7 +39,8 @@ struct AppEnvironment: Sendable {
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir)
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
)
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import ICCeryCore
/// Minimal line chart for drift history without depending on the
/// `Charts` framework link. Renders avg/max series with shaded quality
/// bands.
struct DriftChartView: View {
let records: [VerificationRecord]
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack(alignment: .topLeading) {
if let (_, _, _, maxV) = scales(in: height) {
// Quality bands bottom (red, > 3.5) drawn first, then
// orange, yellow, green so the upper-most bands overlay.
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
}
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
// Average ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.avgDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.blue, lineWidth: 2)
.accessibilityIdentifier("driftAvgSeries")
// Max ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.maxDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.orange, lineWidth: 2)
.accessibilityIdentifier("driftMaxSeries")
} else {
Text("No data")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
private func band(
from lower: Double,
to upper: Double,
color: Color,
height: CGFloat,
maxValue: Double
) -> some View {
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
return color
.frame(height: yBottom - yTop)
.offset(y: yTop)
}
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
return (minT, maxT, 0.0, maxV)
}
private func point(
for record: VerificationRecord,
minTime: Date,
maxTime: Date,
minValue: Double,
maxValue: Double,
width: CGFloat,
height: CGFloat,
keyPath: KeyPath<VerificationRecord, Double>
) -> CGPoint {
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
return CGPoint(x: x, y: y)
}
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
let valueSpan = max(1, maxValue - minValue)
return height - height * CGFloat((value - minValue) / valueSpan)
}
}
@@ -0,0 +1,487 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// User-facing FWA selection for the Stage 4 form.
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
}
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
@Observable
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// 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 = ""
// MARK: - Run state
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false
var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
// MARK: - Install (issue #27)
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
}
// MARK: - Derived
var canCreateProfile: Bool {
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning
}
var canVerify: Bool {
createdProfileURL != nil && !isProfcheckRunning
}
var fwaValue: String? {
switch fwaSelection {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
}
// MARK: - Preset application
func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l"
quality = preset.colprofQuality ?? "m"
intent = preset.colprofIntent ?? ""
if let fwa = preset.colprofFwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
illuminant = preset.colprofIlluminant ?? ""
observer = preset.colprofObserver ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
}
/// Stage 4 form values for saving into a custom preset.
func presetSnapshot() -> (
algorithm: String,
quality: String,
intent: String?,
fwa: String?,
illuminant: String?,
observer: String?,
inputViewingCond: String?,
outputViewingCond: String?
) {
(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond
)
}
// MARK: - Stage 4: build profile
func buildColprofConfig() -> ColprofConfig {
let description = profileDescription.isEmpty ? wizard.basename : profileDescription
return ColprofConfig(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond,
description: description,
copyright: copyright.isEmpty ? nil : copyright,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
}
func createProfile() {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isColprofRunning = false }
do {
let url = try await runner.runColprof(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
}
}
}
var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.colprofLog.append("Gamut mesh extracted.")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
private func updateProgress(_ progress: ColprofProgress) {
switch progress {
case .gamutMapping:
colprofProgress = "Gamut mapping calculation…"
case .fittingClut:
colprofProgress = "Fitting cLUT grid points…"
case .writingIcc:
colprofProgress = "Writing ICC profile…"
case .unknown:
break
}
}
// MARK: - Stage 5: verify profile
var knownPrinters: [String] {
Array(Set(verificationHistory.map { $0.printerName })).sorted()
}
func loadHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.verificationHistory = try await self.environment.historyStore.load()
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} catch {
self.isHistoryStoreError = error.localizedDescription
self.wizard.showNotice(
"Could not load verification history: \(error.localizedDescription)",
kind: .error
)
}
}
}
var filteredHistory: [VerificationRecord] {
guard let filter = driftPrinterFilter, !filter.isEmpty else {
return verificationHistory
}
return verificationHistory.filter { $0.printerName == filter }
}
func verifyProfile() {
guard canVerify,
let cwd = wizard.effectiveWorkingDirectory,
let profileURL = createdProfileURL else { return }
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil
profcheckWarning = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isProfcheckRunning = false }
do {
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
self.verificationHistory = updated
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
self.profcheckWarning = "profcheck output could not be parsed."
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
} catch {
self.profcheckWarning = error.localizedDescription
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
self.wizard.showNotice(
"Verification failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? {
guard let avg = report.avgDE,
let max = report.maxDE,
let rms = report.rmsDE,
let status = report.status else { return nil }
let timestamp = Date()
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
return VerificationRecord(
id: id,
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
printerName: wizard.printerName ?? "",
avgDE: avg,
maxDE: max,
rmsDE: rms,
patchCount: report.patchCount ?? 0,
status: status,
timestamp: timestamp
)
}
private static func nextSeq() -> Int {
Int.random(in: 0..<1_000_000)
}
func clearHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
try await self.environment.historyStore.clear()
self.verificationHistory = []
self.driftAlert = nil
} catch {
self.wizard.showNotice(
"Could not clear history: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - Profile install
func beginInstallProfile() {
guard let sourceURL = createdProfileURL,
let _ = wizard.effectiveWorkingDirectory else { return }
let settings = environment.settingsStore.load()
let preferSystem = settings.defaultInstallLocation == .system
let options = InstallProfileOptions(
forceOverwrite: !settings.askBeforeOverwriteProfile,
preferSystem: preferSystem,
collisionPolicy: .overwrite,
openColorPanel: settings.openColorPanelAfterInstall
)
let destURL = installDestination(for: sourceURL, options: options)
let collision = FileManager.default.fileExists(atPath: destURL.path)
if collision && settings.askBeforeOverwriteProfile {
pendingInstallOptions = options
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
showingInstallCollision = true
return
}
runInstall(sourceURL: sourceURL, options: options)
}
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
showingInstallCollision = false
guard let sourceURL = createdProfileURL,
var options = pendingInstallOptions else { return }
options.collisionPolicy = policy
if policy == .cancel {
installResult = InstallProfileResult(
destPath: "",
registered: false,
overwritten: false,
renamed: false,
openedPanel: false,
message: "Install cancelled."
)
return
}
runInstall(sourceURL: sourceURL, options: options)
}
private func installDestination(for sourceURL: URL, options: InstallProfileOptions) -> URL {
let stem = sourceURL.deletingPathExtension().lastPathComponent
let fm = FileManager.default
let destDir: URL
if options.preferSystem {
destDir = URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
destDir = fm.homeDirectoryForCurrentUser
.appendingPathComponent("Library/ColorSync/Profiles")
}
return destDir.appendingPathComponent("\(stem).icc")
}
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
Task(priority: .userInitiated) { [weak self] in
do {
let result = try ProfileInstaller.install(config: config)
await MainActor.run { [weak self] in
self?.installResult = result
self?.wizard.showNotice(result.message)
}
} catch {
await MainActor.run { [weak self] in
self?.wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
}
func exportHistory() {
guard let url = fileDialogs.selectCsvSavePath() else { return }
Task { @MainActor [weak self] in
guard let self else { return }
let csv = await self.environment.historyStore.exportCSV()
do {
try csv.write(to: url, atomically: true, encoding: .utf8)
self.wizard.showNotice("History exported: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Export failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - File pickers
func browseForSpectrumFile() {
let start = wizard.effectiveWorkingDirectory
let url = UITestHooks.isEnabled
? nil
: fileDialogs.selectSpectrumFile(startingAt: start)
if let url {
fwaSelection = .custom
fwaCustomPath = url.path
}
}
func browseForCalibrationFile() {
let start = wizard.effectiveWorkingDirectory
let url = fileDialogs.selectCalFile(startingAt: start)
if let url {
calibrationFile = url.path
applyCalibration = true
}
}
}
+4
View File
@@ -64,6 +64,10 @@ struct RootView: View {
Stage2View(workflow: workflow)
case .measure:
Stage3View(model: workflow.measurement)
case .buildProfile:
Stage4View(model: workflow.profile)
case .verifyInstall:
Stage5View(model: workflow.profile)
default:
StagePlaceholderView(stage: model.stage)
}
+194
View File
@@ -0,0 +1,194 @@
import SwiftUI
import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
formSection
runSection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(model.wizard.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage4TargetBasename")
Text("Build the ICC profile from the measured .ti3.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage4TargetMeta")
}
Spacer()
if let progress = model.colprofProgress, model.isColprofRunning {
Text(progress)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofProgress")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Form
@ViewBuilder
private var formSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Profile settings")
.font(.headline)
.foregroundStyle(Theme.text)
HStack(spacing: 16) {
Picker("Algorithm", selection: $model.algorithm) {
Text("Lab cLUT").tag("l")
Text("XYZ cLUT").tag("x")
Text("Display XYZ+matrix").tag("X")
Text("Matrix").tag("m")
}
.accessibilityIdentifier("colprofAlgorithm")
Picker("Quality", selection: $model.quality) {
Text("Low").tag("l")
Text("Medium").tag("m")
Text("High").tag("h")
Text("Ultra").tag("u")
}
.accessibilityIdentifier("colprofQuality")
}
Picker("FWA / OBA compensation", selection: $model.fwaSelection) {
ForEach(ColprofFwaSelection.allCases, id: \.self) { selection in
Text(selection.displayName).tag(selection)
}
}
.accessibilityIdentifier("colprofFwa")
if model.fwaSelection == .custom {
HStack {
TextField("Custom .sp spectrum path", text: $model.fwaCustomPath)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofFwaCustomPath")
Button("Browse…") { model.browseForSpectrumFile() }
.accessibilityIdentifier("btnBrowseFwaSp")
}
}
HStack(spacing: 16) {
TextField("Illuminant", text: $model.illuminant)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofIlluminant")
TextField("Observer", text: $model.observer)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofObserver")
}
HStack(spacing: 16) {
TextField("Input viewing condition", text: $model.inputViewingCond)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofInputViewCond")
TextField("Output viewing condition", text: $model.outputViewingCond)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofOutputViewCond")
}
Text("Use 'none' to skip a viewing condition.")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Description", text: $model.profileDescription)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofDescription")
TextField("Copyright", text: $model.copyright)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright")
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Run controls
@ViewBuilder
private var runSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button("Create Profile") {
model.createProfile()
}
.disabled(!model.canCreateProfile)
.accessibilityIdentifier("btnCreateProfile")
if model.isColprofRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("colprofProgressIndicator")
}
Spacer()
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("colprofLastError")
}
}
if !model.colprofLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.colprofLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("colprofLogContainer")
}
}
.padding(16)
.background(Theme.panel)
}
}
+272
View File
@@ -0,0 +1,272 @@
import SwiftUI
import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
verifySection
if let report = model.profcheckReport {
resultSection(report: report)
}
historySection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.loadHistory() }
.alert("Install profile", isPresented: $model.showingInstallCollision) {
Button("Overwrite", role: .destructive) {
model.resolveInstallCollision(policy: .overwrite)
}
.accessibilityIdentifier("profileOverwriteBtn")
Button("Rename") {
model.resolveInstallCollision(policy: .rename)
}
.accessibilityIdentifier("profileRenameBtn")
Button("Cancel", role: .cancel) {
model.resolveInstallCollision(policy: .cancel)
}
.accessibilityIdentifier("profileCancelCollisionBtn")
} message: {
Text(model.installCollisionMessage)
.accessibilityIdentifier("profileInstallCollisionMessage")
}
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(model.wizard.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage5TargetBasename")
Text("Verify the profile and compare against historical results.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage5TargetMeta")
}
Spacer()
if let alert = model.driftAlert {
Text(alert)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.2))
.cornerRadius(4)
.foregroundStyle(.red)
.accessibilityIdentifier("driftAlert")
}
if let warning = model.profcheckWarning, !warning.isEmpty, model.driftAlert == nil {
Text("\(warning)")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.2))
.cornerRadius(4)
.foregroundStyle(.red)
.accessibilityIdentifier("profcheckWarningBanner")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Verify
@ViewBuilder
private var verifySection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button("Verify Profile") {
model.verifyProfile()
}
.disabled(!model.canVerify)
.accessibilityIdentifier("btnVerifyProfile")
if model.isProfcheckRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("profcheckProgressIndicator")
}
Spacer()
if let profileURL = model.createdProfileURL {
Text(profileURL.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage5ProfilePath")
}
}
if !model.colprofLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.colprofLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("profcheckLogContainer")
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Result cards
@ViewBuilder
private func resultSection(report: ProfcheckReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Verification result")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button("Install Profile") { model.beginInstallProfile() }
.disabled(model.createdProfileURL == nil)
.accessibilityIdentifier("btnInstallProfile")
}
HStack(spacing: 16) {
metricCard(title: "Avg ΔE", value: report.avgDE)
metricCard(title: "Max ΔE", value: report.maxDE)
metricCard(title: "RMS", value: report.rmsDE)
metricCard(title: "Patches", value: report.patchCount.map(Double.init))
}
if let status = report.status {
HStack {
Text("Status")
Spacer()
Text(status.displayName)
.fontWeight(.semibold)
.foregroundStyle(statusColor(status))
.accessibilityIdentifier("profcheckStatus")
}
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private func metricCard(title: String, value: Double?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value.map { String(format: "%.2f", $0) } ?? "")
.font(.title3)
.foregroundStyle(Theme.text)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - History
@ViewBuilder
private var historySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("History & drift")
.font(.headline)
.foregroundStyle(Theme.text)
HStack {
Picker("Printer", selection: Binding(
get: { model.driftPrinterFilter ?? "" },
set: { model.driftPrinterFilter = $0.isEmpty ? nil : $0 }
)) {
Text("All").tag("")
ForEach(model.knownPrinters, id: \.self) { printer in
Text(printer.isEmpty ? "Unknown" : printer).tag(printer)
}
}
.accessibilityIdentifier("driftPrinterFilter")
.frame(width: 200)
Spacer()
Button("Export CSV") { model.exportHistory() }
.accessibilityIdentifier("btnExportHistory")
Button("Clear") { model.clearHistory() }
.accessibilityIdentifier("btnClearHistory")
}
driftChart
if !model.filteredHistory.isEmpty {
Table(of: VerificationRecord.self) {
TableColumn("Date") { record in
Text(record.timestamp.formatted(date: .numeric, time: .shortened))
}
TableColumn("Profile") { record in
Text(record.profileName)
}
TableColumn("Avg") { record in
Text(String(format: "%.2f", record.avgDE))
}
TableColumn("Max") { record in
Text(String(format: "%.2f", record.maxDE))
}
TableColumn("RMS") { record in
Text(String(format: "%.2f", record.rmsDE))
}
TableColumn("Status") { record in
Text(record.status.displayName)
.foregroundStyle(statusColor(record.status))
}
} rows: {
ForEach(model.filteredHistory) { record in
TableRow(record)
}
}
.frame(minHeight: 120)
.accessibilityIdentifier("verificationHistoryTable")
} else {
Text("No verification records yet.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private var driftChart: some View {
let records = model.filteredHistory.sorted { $0.timestamp < $1.timestamp }
DriftChartView(records: records)
.frame(height: 160)
.accessibilityIdentifier("driftChart")
}
private func statusColor(_ status: VerificationStatus) -> Color {
switch status {
case .excellent, .good: return .green
case .acceptable: return .yellow
case .poor: return .red
}
}
}
+20 -1
View File
@@ -122,6 +122,9 @@ final class TargetWorkflowViewModel {
/// Stage 3 measurement workflow, owned at the app level so it persists
/// 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 apply preset values.
var profile: ProfileWorkflowViewModel
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -130,6 +133,10 @@ final class TargetWorkflowViewModel {
wizard: wizard,
environment: environment
)
self.profile = ProfileWorkflowViewModel(
wizard: wizard,
environment: environment
)
reloadPresets()
}
@@ -535,6 +542,8 @@ final class TargetWorkflowViewModel {
}
customSeed = preset.randomSeed ?? 1
profile.applyPreset(preset)
selectedPresetID = preset.id
}
@@ -571,7 +580,17 @@ final class TargetWorkflowViewModel {
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster
noRandomize: layoutOrder == .raster,
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil,
colprofAlgorithm: profile.algorithm,
colprofQuality: profile.quality,
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
colprofFwa: profile.fwaValue,
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
)
do {
try environment.presetStore.saveCustom(preset)
@@ -0,0 +1,30 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
@Test("Apply argv")
func applyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
let args = try ApplycalArgs.build(config: config)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
@Test("Unapply is never sent from build")
func unapplyNotEmitted() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
unapply: true
)
let args = try ApplycalArgs.build(config: config)
// Builder intentionally emits -u because config can set it, but
// the UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
}
}
@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
private let lock = NSLock()
private var _lines: [String] = []
func append(_ batch: [String]) {
lock.lock()
_lines.append(contentsOf: batch)
lock.unlock()
}
var lines: [String] {
lock.lock()
defer { lock.unlock() }
return _lines
}
}
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
let testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let runner = ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: binDir)
)
let holder = LogHolder()
let config = ColprofConfig(basename: "testrun", workingDirectory: testRoot)
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
}
@@ -0,0 +1,72 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ColprofArgs")
struct ColprofArgsTests {
@Test("Default algorithm and quality")
func defaults() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
}
@Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
}
@Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
}
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f"))
}
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
}
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D"))
}
@Test("Copyright only when non-empty")
func copyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
}
@Test("No -u passed")
func noProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u"))
}
}
@@ -0,0 +1,24 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ColprofProgress")
struct ColprofProgressTests {
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
}
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
}
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
}
}
@@ -0,0 +1,63 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("DriftAlert")
struct DriftAlertTests {
@Test("No alert with fewer than two poor results")
func notEnough() {
let records = [
record(avg: 4.0, at: 1000)
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Alert on two poor results one hour apart")
func oneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600)
]
#expect(DriftAlert.compute(from: records) != nil)
}
@Test("No alert if same day and under one hour")
func sameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Alert on distinct days")
func distinctDays() {
let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000)
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
}
@Test("Non-poor results do not trigger")
func nonPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
VerificationRecord(
id: "vr-\(Int(offset))",
profileName: "p",
printerName: "",
avgDE: avg,
maxDE: avg,
rmsDE: avg,
patchCount: 1,
status: VerificationStatus.from(avgDE: avg),
timestamp: Date(timeIntervalSince1970: offset)
)
}
}
@@ -0,0 +1,16 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
@@ -0,0 +1,17 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ProfcheckArgs")
struct ProfcheckArgsTests {
@Test("Hard-coded argv")
func argv() throws {
let config = ProfcheckConfig(
ti3URL: URL(fileURLWithPath: "/tmp/target.ti3"),
iccURL: URL(fileURLWithPath: "/tmp/target.icc")
)
let args = try ProfcheckArgs.build(config: config)
#expect(args == ["-v", "-k", "-s", "-u", "/tmp/target.ti3", "/tmp/target.icc"])
}
}
@@ -0,0 +1,71 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ProfcheckParser")
struct ProfcheckParserTests {
@Test("Prefers JSON report with de2000 keys")
func jsonReport() {
let output = """
No of test patches = 52
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
Profile check complete, errors(CIEDE2000): max. = 9.99, avg. = 9.99, RMS = 9.99
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 52)
#expect(report.avgDE == 0.85)
#expect(report.maxDE == 2.41)
#expect(report.rmsDE == 1.02)
#expect(report.status == .excellent)
}
@Test("Falls back to legacy text")
func legacyText() {
let output = """
No of test patches = 120
Profile check complete, errors(CIEDE2000): max. = 3.50, avg. = 1.80, RMS = 0.95
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.patchCount == 120)
#expect(report.avgDE == 1.80)
#expect(report.maxDE == 3.50)
#expect(report.rmsDE == 0.95)
#expect(report.status == .good)
}
@Test("Broad regex fallback")
func regexFallback() {
let output = """
No of test patches = 10
avg = 4.25
max = 6.10
rms = 2.30
"""
let report = ProfcheckParser.parse(output)
#expect(report.isValid == true)
#expect(report.avgDE == 4.25)
#expect(report.maxDE == 6.10)
#expect(report.rmsDE == 2.30)
#expect(report.status == .poor)
}
@Test("Unparseable output warns, not zeros")
func unparseable() {
let output = "some random text without metrics"
let report = ProfcheckParser.parse(output)
#expect(report.isValid == false)
#expect(report.warning != nil)
#expect(report.avgDE == nil)
}
@Test("Status bands")
func statusBands() {
#expect(VerificationStatus.from(avgDE: 0.5) == .excellent)
#expect(VerificationStatus.from(avgDE: 1.5) == .good)
#expect(VerificationStatus.from(avgDE: 2.5) == .acceptable)
#expect(VerificationStatus.from(avgDE: 4.0) == .poor)
}
}
@@ -0,0 +1,75 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("ProfileInstaller")
struct ProfileInstallerTests {
@Test("Copies .icc to user ColorSync folder")
func userInstall() throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let source = tmp.appendingPathComponent("test.icc")
let iccData = Data(repeating: 0, count: 256)
try iccData.write(to: source)
let colorsync = tmp.appendingPathComponent("Library/ColorSync/Profiles")
// Inject a user profile install by replacing the home directory
// is not practical; instead exercise Core validation on a
// temp-only path via the file URL safety checks and the public
// install against a writable system-like path is tested below.
// For this unit test, validate the stem security and source rules.
let unsafe = tmp.appendingPathComponent("bad..stem.icc")
try Data(repeating: 0, count: 256).write(to: unsafe)
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: unsafe))
Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
}
let small = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: small)
do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: small))
Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
@Test("Installs into a temp user folder and preserves source")
func tempInstallPreservesSource() throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let source = tmp.appendingPathComponent("m5_profile.icc")
try Data(repeating: 0, count: 256).write(to: source)
let destDir = tmp.appendingPathComponent("ColorSync/Profiles")
try fm.createDirectory(at: destDir, withIntermediateDirectories: true)
// There is no public API to override the home directory, so
// test the copy mechanism directly via file operations.
let dest = destDir.appendingPathComponent("m5_profile.icc")
let tmpDest = dest.appendingPathExtension("iccery-install.tmp")
try fm.copyItem(at: source, to: tmpDest)
if fm.fileExists(atPath: dest.path) {
_ = try fm.replaceItemAt(dest, withItemAt: tmpDest)
} else {
try fm.moveItem(at: tmpDest, to: dest)
}
#expect(fm.fileExists(atPath: source.path))
#expect(fm.fileExists(atPath: dest.path))
}
}
@@ -0,0 +1,79 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("VerificationHistoryStore")
struct VerificationHistoryStoreTests {
@Test("Append and cap")
func appendAndCap() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let store = VerificationHistoryStore(url: url, capacity: 3)
for i in 0..<5 {
let record = VerificationRecord(
id: "vr-\(i)",
profileName: "p",
printerName: "",
avgDE: Double(i),
maxDE: Double(i),
rmsDE: Double(i),
patchCount: i,
status: .good,
timestamp: Date(timeIntervalSince1970: TimeInterval(i))
)
_ = try await store.append(record)
}
let all = await store.all()
#expect(all.count == 3)
#expect(all.first?.avgDE == 2.0)
}
@Test("Parse failure preserves file")
func parseFailurePreservesFile() async {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
try? "not json".write(to: url, atomically: true, encoding: .utf8)
let store = VerificationHistoryStore(url: url)
do {
_ = try await store.load()
Issue.record("load() should throw on invalid JSON")
} catch {
#expect(fm.fileExists(atPath: url.path))
}
}
@Test("CSV export quoting")
func csvQuoting() async throws {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
let url = tmp.appendingPathComponent("verification_history.json")
let store = VerificationHistoryStore(url: url)
let record = VerificationRecord(
id: "a,b",
profileName: "\"quoted\"",
printerName: "",
avgDE: 1.0,
maxDE: 2.0,
rmsDE: 3.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
_ = try await store.append(record)
let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\""))
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Mock applycal for Milestone 5 UI tests.
# Copies the input profile to the optional output path.
while [ "$#" -gt 0 ]; do
case "$1" in
-v|-a|-u) shift ;;
*) break ;;
esac
done
cal="$1"
input="$2"
output="$3"
if [ -n "$output" ]; then
cp "$input" "$output"
else
cp "$cal" "$input.cal.ctl"
fi
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Mock colprof for Milestone 5 UI tests.
# Writes {basename}.icc next to the last argument and emits progress.
last=""
for arg in "$@"; do last="$arg"; done
if [ "${ICCERY_MOCK_COLPROF_EXIT:-0}" -ne 0 ]; then
echo "mock colprof failure" >&2
exit "$ICCERY_MOCK_COLPROF_EXIT"
fi
echo "Gamut mapping calculation..."
echo "Fitting cLUT grid points..."
echo "Writing ICC profile..."
touch "$last.icc"
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Mock iccgamut for Milestone 5 UI tests.
# Writes {stem}.gam next to the profile path.
last=""
for arg in "$@"; do last="$arg"; done
if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then
echo "mock iccgamut failure" >&2
exit "$ICCERY_MOCK_ICCGAMUT_EXIT"
fi
stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//')
dir=$(dirname "$last")
touch "$dir/$stem.gam"
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Mock profcheck for Milestone 5 UI tests.
# Emits a JSON report and legacy text summary.
if [ "${ICCERY_MOCK_PROFCHECK_EXIT:-0}" -ne 0 ]; then
echo "mock profcheck failure" >&2
exit "$ICCERY_MOCK_PROFCHECK_EXIT"
fi
echo "No of test patches = 52"
sleep 0.1
printf '{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}\n'
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
exit 0
+30 -12
View File
@@ -49,6 +49,23 @@ final class Milestone3UITests: XCTestCase {
testRoot = nil
}
private func waitForFileContent(
_ url: URL,
containing needle: String,
timeout: TimeInterval = 10
) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8),
text.contains(needle) {
return text
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
}
private func launchApp() {
app.launch()
app.activate()
@@ -171,6 +188,14 @@ final class Milestone3UITests: XCTestCase {
reachPrintPanel()
_ = waitFor("printerStatusBadge")
// Wait for the async printer enumeration to select a queue; once
// `btnPrintAll` is enabled, `btnPrintPage-0` is too.
let deadline = Date().addingTimeInterval(15)
while Date() < deadline, !app.buttons["btnPrintAll"].isEnabled {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
app.buttons["btnPrintPage-0"].click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
@@ -202,18 +227,11 @@ final class Milestone3UITests: XCTestCase {
let stateURL = testRoot
.appendingPathComponent("AppData")
.appendingPathComponent("wizard_state.json")
XCTAssertTrue(waitForFile(stateURL))
let data = try Data(contentsOf: stateURL)
let state = String(data: data, encoding: .utf8) ?? ""
XCTAssertTrue(state.contains("Mock_Epson_7450"), state)
let state = waitForFileContent(
stateURL, containing: "Mock_Epson_7450", timeout: 15)
XCTAssertNotNil(state)
XCTAssertTrue((state ?? "").contains("Mock_Epson_7450"))
}
private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return false
}
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import XCTest
/// Milestone 5 UI tests issues #23#27.
@MainActor
final class Milestone5UITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var appDataDir: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui-m5-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
appDataDir = testRoot.appendingPathComponent("AppData")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: appDataDir, withIntermediateDirectories: true)
// Pre-stage a measured .ti3 so the wizard is already on Stage 4.
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("mytarget.ti3").path,
contents: Data("MOCK_TI3".utf8),
attributes: nil)
let state: [String: Any] = [
"currentStage": 4,
"basename": "mytarget",
"cwd": workDir.path,
"printerName": "MockPrinter",
"sessionMode": "profile"
]
let stateData = try JSONSerialization.data(withJSONObject: state, options: [])
try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json"))
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_TEST_WORKDIR": workDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func launchApp() {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 20) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
/// Mock colprof produces a profile, unlocks Stage 5, and the
/// mock profcheck reports Good.
func testBuildProfileAndVerify() throws {
launchApp()
let create = waitFor("btnCreateProfile")
XCTAssertTrue(create.isEnabled)
create.click()
_ = waitFor("btnVerifyProfile", timeout: 30)
// The mock iccgamut should have written a .gam next to the profile.
let gam = workDir.appendingPathComponent("mytarget.gam")
let icc = workDir.appendingPathComponent("mytarget.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: icc.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: gam.path))
app.buttons["btnVerifyProfile"].click()
_ = waitFor("profcheckStatus", timeout: 30)
let statusValue = app.staticTexts["profcheckStatus"].firstMatch.value as? String ?? ""
XCTAssertTrue(
statusValue.contains("Good") || statusValue.contains("Excellent"),
"Expected verification status, got '\(statusValue)'"
)
}
}