Compare commits

...
Author SHA1 Message Date
gronod aef8801913 fix(#28): use manual Binding for gamut sheet so feat/28 does not need TargetWorkflowViewModel changes 2026-09-09 22:13:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0ee609be1c feat(#28): SceneKit gamut viewer
- Add native .gam mesh parser and mesh model (GamutMeshParser, GamutMesh).
- Add GamutView / GamutViewModel with SceneKit, bundled sRGB.gam reference,
  and profile-gamut comparison.
- Wire Stage 5 "View Gamut" button and sidebar quick action.
- Extract a best-effort .gam mesh after colprof via Iccgamut in
  ProfileWorkflowViewModel, with artefact gating for the generated .gam.
- Add BinaryResolver.referenceGamut() for bundled reference gamuts.
- Add Milestone6GamutUITests and GamutMeshParserTests.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 22:06:19 +01:00
gronod a73c6c0b97 Merge pull request 'feat/31-about-help-chrome' (#55) from feat/31-about-help-chrome into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #55
2026-09-09 18:33:26 +01:00
gronod 563f0e5d4a Merge pull request 'Milestone/m6 gamut stage0 cgats release' (#57) from milestone/m6-gamut-stage0-cgats-release into feat/31-about-help-chrome
Reviewed-on: #57
2026-09-09 18:31:47 +01:00
gronod cd4665e7a9 Merge pull request 'feat/30-cgats-interop' (#56) from feat/30-cgats-interop into milestone/m6-gamut-stage0-cgats-release 2026-09-09 17:20:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 153b6194a3 Implement #30: CGATS parser, canonical .ti3 writer, and dataset import.
- Add CGATSParser, CGATSWriter, and CGATSSummary under ICCeryCore/CGATS.
- Parse CTI3, CGATS.17, ISO28178, and CSV; synthesize COLOR_REP/DEVICE_CLASS.
- Scale RGB/CMYK device coordinates from 0-255 to 0-100 when needed.
- Round-trip parse, write, reparse with tab-delimited canonical .ti3.
- Wire `Import Dataset…` in Stage 1 to open-panel import, write .ti3, and
  jump to Stage 4/5 while keeping Stages 1-2 locked without .ti1/.ti2.
- Add CGATSParserTests and Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:18:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f78da50a59 Implement #31: About dialog and global help overlays.
- Add ArtefactFiles.appInfo() build date from executable mtime.
- Add AboutView with version, build, build date, and close button.
- Add HelpOverlay view modifier for non-reflowing help badges.
- Wire About as a sheet and help toggle in SidebarView/RootView.
- Add AboutHelpUITests coverage.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:04:15 +01:00
gronod 629a1fce1d Merge pull request 'Milestone 5 — Profile generation, verification & installation plus M5 bugfixes (#23–#27, #50, #52)' (#54) from milestone/m5-profile into develop 2026-09-09 14:22:10 +01:00
gronod 460b0a1ffa Merge pull request 'M5 bugfixes — Stage 3 prompt stream, ProcessManager, profile/verification fixes (#50, #52)' (#53) from feat/52-m5-bugfixes into milestone/m5-profile 2026-09-09 14:21:42 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ef56cdd7d4 Fix M5 regressions and Stage 3 chartread prompt delivery (#50, #52).
Hardens ProcessManager finalisation, ensures stdout/stderr pipe write-ends
close after spawn, sets termination handlers before run(), and fixes the
runCaptured continuation hand-off for fast exits. Resolves the Stage 3
prompt stream being dropped and the handheld fixture UI not advancing,
unskips and repairs the handheld UI test, and fixes averaging panel
accessibility. Includes the selected #52 profile, verification, and
artefact-gating fixes.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 13:25:39 +01:00
gronod 61c6d62ee2 Merge pull request 'Milestone 5 — Profile generation, verification & installation (#23–#27)' (#51) from feat/23-colprof into milestone/m5-profile 2026-09-09 11:36:35 +01:00
43 changed files with 2789 additions and 248 deletions
@@ -87,6 +87,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("targen") let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename) let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -121,6 +122,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("printtarg") let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename) let processId = ProcessID.printtarg(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -169,6 +171,19 @@ public struct ArgyllRunner: Sendable {
// MARK: - Shared collection // MARK: - Shared collection
/// Cancels any previous child with the same id and waits for it to
/// finalize, so `runStreaming` / `runCaptured` never sees a
/// `duplicateID` from a leftover process (#50, #52).
private func ensureNotRunning(id: String) async {
guard await processManager.isRunning(id) else { return }
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
attempts += 1
}
}
private struct CollectedRun { private struct CollectedRun {
var exitCode: Int32? var exitCode: Int32?
var stdout: String var stdout: String
@@ -179,10 +194,15 @@ public struct ArgyllRunner: Sendable {
/// Drains the event stream until this child's `exit` event. /// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for /// stdout is accumulated both per-line (logs) and verbatim (for
/// the manifest parse the pretty JSON needs its newlines). /// the manifest parse the pretty JSON needs its newlines).
///
/// When `flushPartialLines` is `true`, a background `Task` flushes
/// unterminated output every 500 ms so tools like `colprof` that
/// print dots without newlines still produce log batches.
private func collect( private func collect(
id processId: String, id processId: String,
events: AsyncStream<ProcessEvent>, events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)? onLogBatch: (@Sendable ([String]) -> Void)?,
flushPartialLines: Bool = false
) async -> CollectedRun { ) async -> CollectedRun {
var lines: [String] = [] var lines: [String] = []
var stdout = "" var stdout = ""
@@ -198,6 +218,17 @@ public struct ArgyllRunner: Sendable {
onLogBatch?(out) onLogBatch?(out)
} }
var dotFlushTask: Task<Void, Never>?
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
}
}
for await event in events { for await event in events {
guard event.id == processId else { continue } guard event.id == processId else { continue }
switch event { switch event {
@@ -229,6 +260,12 @@ public struct ArgyllRunner: Sendable {
break break
} }
} }
dotFlushTask?.cancel()
if let dotFlushTask {
_ = await dotFlushTask.value
}
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines) return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
} }
@@ -242,6 +279,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("instlist") let binaryURL = binaryResolver.resolve("instlist")
let processId = ProcessID.instlist let processId = ProcessID.instlist
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -301,6 +339,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("average") let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename) let processId = ProcessID.average(config.basename)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -335,6 +374,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("colprof") let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename) let processId = ProcessID.colprof(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -342,7 +382,12 @@ public struct ArgyllRunner: Sendable {
arguments: args, arguments: args,
workingDirectory: cwd workingDirectory: cwd
) )
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch) let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else { guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed( throw ArgyllRunnerError.colprofFailed(
@@ -368,10 +413,13 @@ public struct ArgyllRunner: Sendable {
/// ///
/// Runs `applycal` captured and performs an in-place replace via /// Runs `applycal` captured and performs an in-place replace via
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp /// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
/// file is removed and the original is left untouched. /// file is removed and the original is left untouched. The UI must
/// never request `unapply` (#52).
public func runApplycal( public func runApplycal(
config: ApplycalConfig config: ApplycalConfig
) async throws -> URL { ) async throws -> URL {
assert(!config.unapply, "runApplycal does not support unapply")
let inputURL = config.inputProfileURL let inputURL = config.inputProfileURL
let cwd = inputURL.deletingLastPathComponent() let cwd = inputURL.deletingLastPathComponent()
let binaryURL = binaryResolver.resolve("applycal") let binaryURL = binaryResolver.resolve("applycal")
@@ -383,6 +431,8 @@ public struct ArgyllRunner: Sendable {
// Remove any stale tmp from a previous crash. // Remove any stale tmp from a previous crash.
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
await ensureNotRunning(id: processId)
let outputConfig = ApplycalConfig( let outputConfig = ApplycalConfig(
calibrationPath: config.calibrationPath, calibrationPath: config.calibrationPath,
inputProfileURL: inputURL, inputProfileURL: inputURL,
@@ -398,8 +448,11 @@ public struct ArgyllRunner: Sendable {
workingDirectory: cwd workingDirectory: cwd
) )
guard result.exitCode == 0 else { guard result.exitCode == 0, !Task.isCancelled else {
try? fm.removeItem(at: tmpURL) try? fm.removeItem(at: tmpURL)
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.applycalFailed( throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)" ? "applycal exited with code \(result.exitCode)"
@@ -413,6 +466,15 @@ public struct ArgyllRunner: Sendable {
) )
} }
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(
"calibrated profile is too small (\(size) bytes)"
)
}
do { do {
if fm.fileExists(atPath: inputURL.path) { if fm.fileExists(atPath: inputURL.path) {
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL) _ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
@@ -441,6 +503,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("iccgamut") let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem) let processId = ProcessID.iccgamut(stem: stem)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -480,6 +543,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("profcheck") let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path) let processId = ProcessID.profcheck(ti3Path: ti3Path)
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -547,11 +611,21 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("chartread") let binaryURL = binaryResolver.resolve("chartread")
let processId = ProcessID.chartread(cleanBasename) let processId = ProcessID.chartread(cleanBasename)
let processManager = self.processManager let processManager = self.processManager
let isXY = config.isXY
return AsyncStream { continuation in return AsyncStream { continuation in
let task = Task { let task = Task {
await ensureNotRunning(id: processId)
let events = processManager.events() let events = processManager.events()
// Register the XY parking hook before spawning.
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
}
}
do { do {
try await processManager.runStreaming( try await processManager.runStreaming(
id: processId, id: processId,
@@ -582,20 +656,22 @@ public struct ArgyllRunner: Sendable {
switch event { switch event {
case .stdout(_, let line): case .stdout(_, let line):
let classified = ChartreadClassifier.classify(line: line, previousState: state) let previous = state
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
state = classified.state state = classified.state
if classified.isRemoveSheetNotice { if classified.isRemoveSheetNotice {
continuation.yield(.removeSheetNotice) continuation.yield(.removeSheetNotice)
} }
if classified.sheetNumber != nil || classified.alignmentPatch != nil {
continuation.yield(.prompt(classified)) let shouldPrompt =
} else if state != previousOrContinuationState(state, classified) { classified.sheetNumber != nil
// Only emit prompt when the state meaningfully changes. || classified.alignmentPatch != nil
continuation.yield(.prompt(classified)) || classified.requestedWarningKey != nil
} else if state == .tablePlaceSheet || state == .tableAlign { || classified.state != previous
// Continuation lines in table states are still prompts. || classified.isTableContinuation
continuation.yield(.prompt(classified))
} else if classified.requestedWarningKey != nil { if shouldPrompt {
continuation.yield(.prompt(classified)) continuation.yield(.prompt(classified))
} }
@@ -632,6 +708,11 @@ public struct ArgyllRunner: Sendable {
} }
} }
if Task.isCancelled {
continuation.finish()
return
}
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3") let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
if let code = exitCode, code == 0 { if let code = exitCode, code == 0 {
if FileManager.default.fileExists(atPath: canonical.path) { if FileManager.default.fileExists(atPath: canonical.path) {
@@ -647,15 +728,13 @@ public struct ArgyllRunner: Sendable {
continuation.onTermination = { _ in continuation.onTermination = { _ in
task.cancel() task.cancel()
Task {
await processManager.kill(id: processId)
}
} }
} }
} }
private func previousOrContinuationState(_ state: ChartreadState, _ classified: ChartreadClassifyResult) -> ChartreadState {
if classified.isTableContinuation { return .promptContinue }
return state
}
/// Send an exact input sequence to the running `chartread` child. /// Send an exact input sequence to the running `chartread` child.
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws { public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
let cleanBasename = try PathSecurity.sanitizeBasename(basename) let cleanBasename = try PathSecurity.sanitizeBasename(basename)
@@ -665,17 +744,14 @@ public struct ArgyllRunner: Sendable {
/// Terminate a running `chartread` child. /// Terminate a running `chartread` child.
/// ///
/// For XY tables, sends `q\n` first and waits ~500 ms so the head parks. /// The actual XY parking is handled by the pre-kill hook registered in
/// `runChartread`.
public func cancelChartread(basename: String, isXY: Bool = false) { public func cancelChartread(basename: String, isXY: Bool = false) {
let cleanBasename = try? PathSecurity.sanitizeBasename(basename) let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
guard let cleanBasename else { return } guard let cleanBasename else { return }
let processId = ProcessID.chartread(cleanBasename) let processId = ProcessID.chartread(cleanBasename)
Task { Task {
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
}
await processManager.kill(id: processId) await processManager.kill(id: processId)
} }
} }
@@ -88,9 +88,10 @@ public struct BinaryResolver: Sendable {
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`). /// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL { public func referenceGamut(_ name: String) -> URL {
bundledRoot let stem = name.hasSuffix(".gam") ? name : "\(name).gam"
return bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true) .appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false) .appendingPathComponent(stem, isDirectory: false)
} }
/// Whether the resolved path exists and is executable. /// Whether the resolved path exists and is executable.
@@ -0,0 +1,372 @@
import Foundation
/// Errors that can occur while parsing CGATS-like data.
public enum CGATSParseError: Error, Equatable {
case emptyFile
case missingBeginDataFormat
case missingEndDataFormat
case missingBeginData
case missingEndData
case missingNumberOfFields
case missingNumberOfSets
case unknownFieldName(String)
case malformedRow(line: Int, reason: String)
case nonNumericValue(field: String, value: String, line: Int)
case outOfBoundsValue(field: String, value: Double, line: Int)
case implausibleValue(field: String, value: Double, line: Int)
case incorrectArity(line: Int, expected: Int, got: Int)
}
/// One row of a CGATS dataset, keyed by canonical field name.
public struct CGATSSample: Sendable, Equatable {
public var id: String
public var loc: String?
public var values: [String: String]
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
self.id = id
self.loc = loc
self.values = values
}
}
/// A parsed CGATS / CTI3 / CSV dataset.
public struct CGATSDataset: Sendable, Equatable {
public var format: CGATSFormat
public var keywords: [String: String]
public var fieldNames: [String]
public var samples: [CGATSSample]
public var colorRep: String?
public var deviceClass: String?
public var targetInstrument: String?
public init(
format: CGATSFormat,
keywords: [String: String] = [:],
fieldNames: [String] = [],
samples: [CGATSSample] = [],
colorRep: String? = nil,
deviceClass: String? = nil,
targetInstrument: String? = nil
) {
self.format = format
self.keywords = keywords
self.fieldNames = fieldNames
self.samples = samples
self.colorRep = colorRep
self.deviceClass = deviceClass
self.targetInstrument = targetInstrument
}
}
public enum CGATSFormat: String, Sendable, Equatable {
case cti3 = "CTI3"
case cgats17 = "CGATS.17"
case csv = "CSV"
}
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
public enum CGATSParser {
/// Parse the contents of a CGATS-like file.
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix("SAMPLE_ID,")
let (format, lines) = try preprocess(contents, isCSV: isCSV)
var formatStart: Int?
var formatEnd: Int?
var dataStart: Int?
var dataEnd: Int?
var keywords = [String: String]()
for (index, line) in lines.enumerated() {
switch Self.normalizedKeyword(line) {
case "BEGIN_DATA_FORMAT": formatStart = index
case "END_DATA_FORMAT": formatEnd = index
case "BEGIN_DATA": dataStart = index
case "END_DATA": dataEnd = index
default:
if let (key, value) = parseKeyword(line) {
keywords[key] = value
}
}
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
numberOfFields != fieldNames.count {
// Warn only; the data format line is the source of truth.
} else if keywords["NUMBER_OF_FIELDS"] == nil {
// Optional header; do not fail.
}
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
numberOfSets != dataEnd - dataStart - 1 {
// Warn only; the actual rows are the source of truth.
} else if keywords["NUMBER_OF_SETS"] == nil {
// Optional header; do not fail.
}
struct RawSample {
var id: String
var loc: String?
var numbers: [String: Double] = [:]
var strings: [String: String] = [:]
var lineIndex: Int
}
var rawSamples = [RawSample]()
var groupMax: [String: Double] = [:]
for offset in 1...(dataEnd - dataStart - 1) {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
for (i, name) in fieldNames.enumerated() {
let raw = stripInlineComment(rawRow[i])
if isNumericField(name) {
let cleaned = raw.trimmingCharacters(in: .whitespaces)
if let number = parseNumber(cleaned) {
sample.numbers[name] = number
if let group = deviceGroup(name) {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
}
}
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
sample.loc = sample.strings["SAMPLE_LOC"]
rawSamples.append(sample)
}
var samples = [CGATSSample]()
for raw in rawSamples {
var values = raw.strings
for (name, number) in raw.numbers {
var scaled = number
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
scaled = number / 2.55
}
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
}
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
// Keep lookups by canonical keys, but also preserve original aliases.
let rawRow = splitFields(lines[raw.lineIndex])
for (i, rawName) in rawFieldNames.enumerated() {
let canonical = canonicalFieldName(rawName)
if canonical != rawName {
sample.values[rawName] = rawRow[i]
}
}
samples.append(sample)
}
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
return CGATSDataset(
format: format,
keywords: keywords,
fieldNames: fieldNames,
samples: samples,
colorRep: colorRep,
deviceClass: deviceClass,
targetInstrument: keywords["TARGET_INSTRUMENT"]
)
}
/// Parse from a URL (throws as `Error` for public callers).
public static func parse(url: URL) throws -> CGATSDataset {
let contents = try String(contentsOf: url)
return try parse(contents, sourceURL: url)
}
// MARK: - Internals
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
var format: CGATSFormat?
for var line in allLines {
line = stripComment(line)
line = line.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty else { continue }
if format == nil {
if line.hasPrefix("CTI3") { format = .cti3 }
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
else if isCSV { format = .csv }
}
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
line == "BEGIN_DATA" || line == "END_DATA" ||
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
// These are exact keywords; keep them intact.
}
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
if let format, format == .csv,
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
let header = lines[0]
let data = lines.dropFirst()
lines = [
"CTI3",
"BEGIN_DATA_FORMAT",
header,
"END_DATA_FORMAT",
"BEGIN_DATA"
] + Array(data) + [
"END_DATA"
]
return (.csv, lines)
}
return (format ?? .cti3, lines)
}
private static func stripComment(_ line: String) -> String {
if let range = line.range(of: "#") {
return String(line[..<range.lowerBound])
}
return line
}
private static func stripInlineComment(_ token: String) -> String {
if let range = token.range(of: "#") {
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
}
return token
}
private static func splitFields(_ line: String) -> [String] {
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
if line.contains(",") {
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
}
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
// KEYWORD value or KEYWORD "value"
let tokens = splitFields(line)
guard let key = tokens.first else { return nil }
// Data-boundary keywords are not value keywords.
let boundaryKeys = Set([
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
"BEGIN_DATA", "END_DATA"
])
guard !boundaryKeys.contains(key) else { return nil }
let rawValue = tokens.dropFirst().joined(separator: " ")
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return (key, value)
}
private static func normalizedKeyword(_ line: String) -> String {
line.uppercased().trimmingCharacters(in: .whitespaces)
}
// MARK: - Field name normalization
private static func canonicalFieldName(_ raw: String) -> String {
let upper = raw.uppercased()
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "-", with: "_")
switch upper {
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
case "SAMPLE_NAME": return "SAMPLE_ID"
case "LAB_L", "L*", "L_AB": return "LAB_L"
case "LAB_A", "A*", "A_AB": return "LAB_A"
case "LAB_B", "B*", "B_AB": return "LAB_B"
case "XYZ_X", "X": return "XYZ_X"
case "XYZ_Y", "Y": return "XYZ_Y"
case "XYZ_Z", "Z": return "XYZ_Z"
default: return upper
}
}
private static func isNumericField(_ name: String) -> Bool {
let numericNames: Set = [
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
]
return !numericNames.contains(name)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
var number = value
// Plausibility checks for Lab and XYZ.
if field == "LAB_L" { number = max(0, min(160, number)) }
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
return String(format: "%.4f", number)
}
private static func deviceGroup(_ name: String) -> String? {
if name.hasPrefix("RGB_") { return "RGB" }
if name.hasPrefix("CMYK_") { return "CMYK" }
if name.hasPrefix("DEVICE_") { return "DEVICE" }
return nil
}
private static func inferColorRep(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
return nil
}
private static func inferDeviceClass(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
return "OUTPUT"
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Human-readable summary of an imported CGATS dataset.
public struct CGATSSummary: Sendable, Equatable {
public let patchCount: Int
public let colorSpace: String?
public let deviceClass: String?
public let hasSpectral: Bool
public let previewRows: [String]
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
self.patchCount = dataset.samples.count
self.colorSpace = dataset.colorRep
self.deviceClass = dataset.deviceClass
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
})
}
}
@@ -0,0 +1,82 @@
import Foundation
/// Errors from writing a canonical `.ti3` dataset.
public enum CGATSWriterError: Error, Equatable {
case noSamples
case missingRequiredField(String)
case invalidValue(field: String, value: String)
}
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
public enum CGATSWriter {
public static func write(_ dataset: CGATSDataset) throws -> String {
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
throw CGATSWriterError.noSamples
}
var lines = [String]()
// Header
lines.append(dataset.format.rawValue)
lines.append("")
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
if let colorRep = dataset.colorRep {
lines.append("COLOR_REP \"\(colorRep)\"")
}
if let deviceClass = dataset.deviceClass {
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
}
if let instrument = dataset.targetInstrument {
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
}
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
lines.append("")
lines.append("BEGIN_DATA_FORMAT")
lines.append(dataset.fieldNames.joined(separator: "\t"))
lines.append("END_DATA_FORMAT")
lines.append("")
lines.append("BEGIN_DATA")
for sample in dataset.samples {
let row = try dataset.fieldNames.map { field in
guard let raw = sample.values[field], !raw.isEmpty else {
throw CGATSWriterError.missingRequiredField(field)
}
// Normalize numeric fields to a compact decimal.
if isNumeric(field) {
return normalizedNumber(raw)
}
return raw
}
lines.append(row.joined(separator: "\t"))
}
lines.append("END_DATA")
return lines.joined(separator: "\n") + "\n"
}
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
let text = try write(dataset)
try text.write(to: url, atomically: true, encoding: .utf8)
}
// MARK: - Internals
private static func isNumeric(_ field: String) -> Bool {
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
return !nonNumeric.contains(field)
}
private static func normalizedNumber(_ raw: String) -> String {
guard let number = Double(raw) else { return raw }
if number == floor(number) {
return String(format: "%.0f", number)
}
return String(format: "%.4f", number)
}
}
@@ -15,14 +15,26 @@ public enum ArtefactFiles {
try Data(contentsOf: url).base64EncodedString() try Data(contentsOf: url).base64EncodedString()
} }
/// `get_app_info` version + build for the About dialog. /// `get_app_info` version, build, and build date for the About dialog.
public static func appInfo( public static func appInfo(
bundle: Bundle = .main bundle: Bundle = .main
) -> (version: String, build: String) { ) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:] let info = bundle.infoDictionary ?? [:]
return ( let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
info["CFBundleShortVersionString"] as? String ?? "0.0.0", let build = info["CFBundleVersion"] as? String ?? "0"
info["CFBundleVersion"] as? String ?? "0"
) let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
let date = values.contentModificationDate {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
buildDate = formatter.string(from: date)
} else {
buildDate = "Unknown"
}
return (version, build, buildDate)
} }
} }
@@ -12,19 +12,23 @@ public struct StageArtefacts: Sendable, Equatable {
public var stage4Complete = false public var stage4Complete = false
/// Absolute path of the profile file when present. /// Absolute path of the profile file when present.
public var profilePath: URL? public var profilePath: URL?
/// Absolute path of the `.gam` gamut mesh when present (issue #28).
public var gamPath: URL?
public init( public init(
stage1Complete: Bool = false, stage1Complete: Bool = false,
stage2Complete: Bool = false, stage2Complete: Bool = false,
stage3Complete: Bool = false, stage3Complete: Bool = false,
stage4Complete: Bool = false, stage4Complete: Bool = false,
profilePath: URL? = nil profilePath: URL? = nil,
gamPath: URL? = nil
) { ) {
self.stage1Complete = stage1Complete self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete self.stage4Complete = stage4Complete
self.profilePath = profilePath self.profilePath = profilePath
self.gamPath = gamPath
} }
} }
@@ -45,6 +49,10 @@ public enum ArtefactProbe {
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) { if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true out.stage4Complete = true
out.profilePath = profile out.profilePath = profile
let gam = artefact(basename, "gam", cwd)
if exists(gam, fm: fileManager) {
out.gamPath = gam
}
} }
return out return out
} }
@@ -0,0 +1,51 @@
import Foundation
import simd
/// A single vertex of an Argyll `.gam` surface mesh.
///
/// Coordinates follow the v0.8.5 SceneKit convention: `x = a*`, `y = L*`,
/// `z = b*` so that the a* (green-red) axis is horizontal, L* (lightness)
/// is vertical, and b* (blue-yellow) is depth.
public struct GamutVertex: Sendable, Equatable {
public let lab: LabColor
public let rgb: DisplayRGB
public let position: SIMD3<Float>
public init(lab: LabColor, rgb: DisplayRGB) {
self.lab = lab
self.rgb = rgb
self.position = SIMD3<Float>(Float(lab.a), Float(lab.l), Float(lab.b))
}
}
/// A face from an Argyll `.gam` file.
///
/// Indices are 0-based and index into `GamutMesh.vertices` in the order the
/// vertices were pushed by the parser (the `VERTEX_NO` column is discarded).
public struct GamutTriangle: Sendable, Equatable {
public let a: UInt32
public let b: UInt32
public let c: UInt32
public init(a: UInt32, b: UInt32, c: UInt32) {
self.a = a
self.b = b
self.c = c
}
}
/// Parsed gamut surface mesh.
public struct GamutMesh: Sendable, Equatable {
public let vertices: [GamutVertex]
public let faces: [GamutTriangle]
public init(vertices: [GamutVertex], faces: [GamutTriangle]) {
self.vertices = vertices
self.faces = faces
}
/// A printable summary for diagnostics.
public var summary: String {
"GamutMesh(vertices: \(vertices.count), faces: \(faces.count))"
}
}
@@ -0,0 +1,152 @@
import Foundation
/// Errors thrown by ``GamutMeshParser``.
public enum GamutMeshParseError: LocalizedError, Equatable, Sendable {
case missingFile
case readFailed(underlying: String)
case emptyFile
case noDataBlock
case malformedVertexLine(line: Int, content: String)
case malformedFaceLine(line: Int, content: String)
case outOfBoundsVertexIndex(UInt32, max: UInt32)
case invalidLabPlausibility(line: Int, content: String)
public var errorDescription: String? {
switch self {
case .missingFile:
return "Gamut file not found."
case .readFailed(let reason):
return "Could not read gamut file: \(reason)"
case .emptyFile:
return "Gamut file is empty."
case .noDataBlock:
return "Gamut file contains no BEGIN_DATA blocks."
case .malformedVertexLine(let line, let content):
return "Malformed vertex on line \(line): \(content)"
case .malformedFaceLine(let line, let content):
return "Malformed face on line \(line): \(content)"
case .outOfBoundsVertexIndex(let index, let max):
return "Face references vertex \(index) but only \(max + 1) vertices exist."
case .invalidLabPlausibility(let line, let content):
return "Lab value outside plausible range on line \(line): \(content)"
}
}
}
/// Parses Argyll `.gam` ASCII files into ``GamutMesh``.
///
/// The parser recognises two `BEGIN_DATA` `END_DATA` blocks:
///
/// 1. Vertices: `VERTEX_NO LAB_L LAB_A LAB_B`
/// 2. Faces: `VERTEX_0 VERTEX_1 VERTEX_2` (0-based indices)
///
/// Lines beginning with `#` and blank lines are ignored. `BEGIN_DATA` and
/// `END_DATA` are matched case-insensitively. The `VERTEX_NO` column is
/// discarded; vertices are indexed in push order, matching Argyll's output.
public enum GamutMeshParser {
/// Parse the file at `url`.
public static func parse(url: URL) throws -> GamutMesh {
guard FileManager.default.fileExists(atPath: url.path) else {
throw GamutMeshParseError.missingFile
}
guard let data = FileManager.default.contents(atPath: url.path) else {
throw GamutMeshParseError.readFailed(underlying: "contents(atPath:) returned nil")
}
guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii),
!text.isEmpty else {
throw GamutMeshParseError.emptyFile
}
return try parse(text: text)
}
/// Parse raw `.gam` text.
public static func parse(text: String) throws -> GamutMesh {
var vertices: [GamutVertex] = []
var faces: [GamutTriangle] = []
var dataBlock = 0
var inData = false
var lineNumber = 0
var warnings: [String] = []
for rawLine in text.components(separatedBy: .newlines) {
lineNumber += 1
// Strip inline `#` comments before any other processing.
let uncommented = rawLine.split(separator: "#", maxSplits: 1).first.map(String.init) ?? ""
let trimmed = uncommented.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let upper = trimmed.uppercased()
if upper == "BEGIN_DATA" {
dataBlock += 1
inData = true
continue
}
if upper == "END_DATA" {
inData = false
continue
}
if !inData { continue }
let parts = trimmed.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
.compactMap(Double.init)
guard !parts.isEmpty else { continue }
if dataBlock == 1 {
// Vertex format: index L a b
guard parts.count >= 4 else {
warnings.append("vertex arity \(parts.count) on line \(lineNumber)")
continue
}
let l = parts[1]
let a = parts[2]
let b = parts[3]
if l < 0 || l > 100 || abs(a) > 128 || abs(b) > 128 {
warnings.append("Lab plausibility warning on line \(lineNumber): L=\(l) a=\(a) b=\(b)")
// We still keep the vertex; Argyll can exceed ±128.
}
let lab = LabColor(l: l, a: a, b: b)
let rgb = LabColorMath.labToSRGB(lab)
vertices.append(GamutVertex(lab: lab, rgb: rgb))
} else {
// Face format: v0 v1 v2 (can extend for future n-gons, take first 3)
guard parts.count >= 3 else {
warnings.append("face arity \(parts.count) on line \(lineNumber)")
continue
}
let idx = parts.prefix(3).compactMap { UInt32(exactly: $0) }
guard idx.count == 3 else {
warnings.append("non-integer face indices on line \(lineNumber)")
continue
}
faces.append(GamutTriangle(a: idx[0], b: idx[1], c: idx[2]))
}
}
// Trim out-of-bounds face indices instead of throwing, so a slightly
// malformed file still renders. This matches the Web viewer's
// forgiving posture while surfacing the obvious cases.
let validFaces = faces.filter { face in
let max = UInt32(vertices.count)
guard face.a < max, face.b < max, face.c < max else {
warnings.append("dropping face \(face) referencing missing vertex")
return false
}
return true
}
if dataBlock == 0 {
throw GamutMeshParseError.noDataBlock
}
return GamutMesh(vertices: vertices, faces: validFaces)
}
}
@@ -153,7 +153,7 @@ public enum ChartreadClassifier {
let phrases = [ let phrases = [
"'d' if/when done", "d to finish/save", "all strips/patches read", "'d' if/when done", "d to finish/save", "all strips/patches read",
"all strips read", "all patches read", "done reading", "all strips read", "all patches read", "done reading",
"'d' to save", "press d to", "hit 'd'" "'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
] ]
if phrases.contains(where: { text.contains($0) }) { if phrases.contains(where: { text.contains($0) }) {
return ChartreadClassifyResult(state: .allStripsRead) return ChartreadClassifyResult(state: .allStripsRead)
@@ -163,20 +163,28 @@ public enum ChartreadClassifier {
// 7. Warnings / prompts needing a key. // 7. Warnings / prompts needing a key.
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text
let warningSignals = [ let warningSignals = [
"(warning)", "use it anyway", "seem to have read strip pass", "(warning)", "use it anyway", "seem to have read strip",
"unexpected response", "seem to have read", "misread", "unexpected response", "try again", "do you want to",
"try again", "do you want to" "abort ? - are you sure", "are you sure"
] ]
guard warningSignals.contains(where: { text.contains($0) }) else { return nil }
let isWarningPrompt =
warningSignals.contains(where: { lower.contains($0) })
|| lower.contains("(y/n)")
|| lower.contains("'y' or 'n'")
|| lower.contains("?")
guard isWarningPrompt else { return nil }
var key: String? var key: String?
if text.contains("(y/n)") || text.contains("'y' or 'n'") { if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
// Default to asking the user; no automatic key. // Default to asking the user; no automatic key.
key = nil key = nil
} else if text.contains("'y'") || text.contains("press y") || text.contains("hit 'y'") { } else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
key = "y" key = "y"
} else if text.contains("'n'") || text.contains("press n") || text.contains("hit 'n'") { } else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
key = "n" key = "n"
} }
@@ -195,12 +203,14 @@ public enum ChartreadClassifier {
!hasLocate !hasLocate
else { return nil } else { return nil }
if lowercased.contains("hit any key to continue") if lowercased.contains("calibrat")
|| lowercased.contains("hit space to continue") || lowercased.contains("white reference")
|| lowercased.contains("calibration")
|| lowercased.contains("calibrate")
|| lowercased.contains("white tile") || lowercased.contains("white tile")
|| lowercased.contains("standard tile") { || lowercased.contains("standard tile")
|| lowercased.contains("reference")
|| lowercased.contains("tile")
|| lowercased.contains("hit any key to continue")
|| lowercased.contains("hit space to continue") {
return ChartreadClassifyResult(state: .calibrating) return ChartreadClassifyResult(state: .calibrating)
} }
return nil return nil
@@ -209,11 +219,29 @@ public enum ChartreadClassifier {
// 9. Awaiting strip. // 9. Awaiting strip.
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased() let lowercased = text.lowercased()
// These are explicit, multi-word prompts; we deliberately do NOT
// match bare "read strip" so that error lines like
// "failed to read strip" or "error reading strip" fall through to
// the error matcher.
let phrases = [ let phrases = [
"hit ... read ... strip", "ready to read", "read ... strip ... key", "ready to read",
"hit any key to read", "ready to read strip", "hit a key to read", "hit any key to read",
"press any key to read", "read strip" "hit a key to read",
"hit space to read",
"hit [space] to read",
"press any key to read",
"press space to read",
"trigger instrument",
"start reading",
"read next strip"
] ]
// Also permit "hit X to read strip Y" or "ready to read strip Z".
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .awaitingStrip)
}
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil } guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .awaitingStrip) return ChartreadClassifyResult(state: .awaitingStrip)
} }
@@ -228,16 +256,27 @@ public enum ChartreadClassifier {
// 11. Error. // 11. Error.
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? { private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = ["error", "too fast", "too slow", "misread", "failed to read", "failed"] let lower = text.lowercased()
// Avoid false positives inside harmless words by matching full words where possible.
let lower = text
guard phrases.contains(where: { phrase in
lower.contains(phrase) && !lower.contains("no error")
}) else { return nil }
if lower.contains("misread") || lower.contains("failed to read") || lower.contains("error") { // Avoid false positives from confirmation prompts and "no error" status.
guard !lower.contains("no error") else { return nil }
guard !lower.contains("(y/n)")
&& !lower.contains("'y' or 'n'")
&& !lower.contains("?")
else { return nil }
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
for phrase in phraseMatches {
if lower.contains(phrase) {
return ChartreadClassifyResult(state: .error)
}
}
// Whole-word "error" only bare "failed" alone is not enough.
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .error) return ChartreadClassifyResult(state: .error)
} }
return nil return nil
} }
} }
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
return rest.isEmpty ? nil : Self.decode(rest) return rest.isEmpty ? nil : Self.decode(rest)
} }
/// Emits the current unterminated tail as a single line and clears it.
/// Used by `ProcessManager.flushPartialLine` for tools that emit
/// progress dots without newlines.
public mutating func flushPartial() -> String? {
guard !pending.isEmpty else { return nil }
var rest = pending
pending.removeAll(keepingCapacity: false)
if rest.last == 0x0D { rest = rest.dropLast() }
let text = Self.decode(rest)
return text.isEmpty ? nil : text
}
private static func decode(_ bytes: Data.SubSequence) -> String { private static func decode(_ bytes: Data.SubSequence) -> String {
String(decoding: bytes, as: UTF8.self) String(decoding: bytes, as: UTF8.self)
} }
@@ -20,8 +20,11 @@ public struct CapturedResult: Sendable, Equatable {
/// with the prefix stripped; all other stdout is `stdout` events. /// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both /// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast /// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills. /// exits or kills. If EOFs never arrive, a watchdog finalizes.
/// - `kill` drops the stdin handle so writers fail fast. /// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
/// terminating. Hooks are removed once the child finalizes.
/// - `killAll` on `NSApplication.willTerminate` and last-window close
/// runs all hooks and terminates every child (#147, #149).
public actor ProcessManager { public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: " public static let rowColorsPrefix = "ROW_COLORS_JSON: "
@@ -92,12 +95,18 @@ public actor ProcessManager {
/// pipes have also reached EOF. /// pipes have also reached EOF.
var pendingExitCode: Int32? var pendingExitCode: Int32?
var finalized = false var finalized = false
/// Watchdog that forces finalization if EOFs never arrive.
var finalizeTask: Task<Void, Never>?
} }
private var children: [String: RunningChild] = [:] private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support). /// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:] private var captured: [String: Process] = [:]
/// Hooks run by `kill` before terminating the child.
/// Used by `chartread` to park an XY head with `q\n`.
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
/// Ids of currently-running children. /// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys } public var runningIDs: [String] { Array(children.keys) + captured.keys }
@@ -105,6 +114,14 @@ public actor ProcessManager {
children[id] != nil || captured[id] != nil children[id] != nil || captured[id] != nil
} }
// MARK: - Pre-kill hooks
/// Register a hook to run before `kill(id:)` terminates the child.
/// The hook is removed once the child finalizes.
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
preKillHooks[id] = hook
}
// MARK: - Spawn (streaming) // MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for /// Spawns a streaming child. Returns after spawn; callers wait for
@@ -142,14 +159,6 @@ public actor ProcessManager {
stderrDecoder: ProcessLineDecoder() stderrDecoder: ProcessLineDecoder()
) )
do {
try process.run()
} catch {
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
let stdoutHandle = stdoutPipe.fileHandleForReading let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in stdoutHandle.readabilityHandler = { [weak self] handle in
@@ -167,6 +176,15 @@ public actor ProcessManager {
guard let self else { return } guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) } Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
} }
do {
try process.run()
} catch {
preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
} }
// MARK: - Spawn (captured) // MARK: - Spawn (captured)
@@ -198,41 +216,114 @@ public actor ProcessManager {
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))" "spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
) )
// Register before run() so a concurrent duplicate spawn fails. // Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52).
captured[id] = process captured[id] = process
let capturedProcess = process
// Box is local and synchronised with an NSLock; the @unchecked
// Sendable annotation is safe because all access is under the lock.
final class Box: @unchecked Sendable {
private let lock = NSLock()
private var status: Int32?
private var continuation: CheckedContinuation<Int32, Never>?
/// Try to resume an already-stored continuation with the exit
/// status. Returns true if a continuation was resumed.
func resume(with status: Int32) -> Bool {
lock.lock()
if let cont = continuation {
continuation = nil
lock.unlock()
cont.resume(returning: status)
return true
}
self.status = status
lock.unlock()
return false
}
/// Store a continuation, returning any status that arrived
/// before it. The caller must resume with the returned status.
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
lock.lock()
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
self.continuation = continuation
// A fast exit may have raced past the first nil-check.
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
lock.unlock()
return nil
}
}
let box = Box()
capturedProcess.terminationHandler = { proc in
_ = box.resume(with: proc.terminationStatus)
}
do { do {
try process.run() try process.run()
} catch { } catch {
_ = box.resume(with: -1)
captured.removeValue(forKey: id) captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription)) emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)") throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
} }
async let outData = Task.detached { // Close the parent write ends so readDataToEndOfFile() gets EOF
stdoutPipe.fileHandleForReading.readDataToEndOfFile() // as soon as the child exits; the child still has its own copies.
}.value try? stdoutPipe.fileHandleForWriting.close()
async let errData = Task.detached { try? stderrPipe.fileHandleForWriting.close()
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in return await withTaskCancellationHandler {
process.terminationHandler = { proc in async let outData = Task.detached {
continuation.resume(returning: proc.terminationStatus) stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
async let errData = Task.detached {
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in
if let status = box.store(continuation) {
continuation.resume(returning: status)
}
}
let (out, err) = await (outData, errData)
// Emit the real exit code once, regardless of whether kill()
// already removed the id from `captured`.
_ = captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.exit(id: id, code: code))
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
} onCancel: { [weak self] in
// If the awaiting Task is cancelled, terminate the child so
// callers like runApplycal never replace a good profile with
// a truncated tmp.
if capturedProcess.isRunning {
capturedProcess.terminate()
}
Task { [weak self] in
await self?.kill(id: id)
} }
} }
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: code))
}
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
} }
// MARK: - stdin // MARK: - stdin
@@ -255,39 +346,89 @@ public actor ProcessManager {
try sendStdin(id: id, bytes: Data(text.utf8)) try sendStdin(id: id, bytes: Data(text.utf8))
} }
// MARK: - Partial-line flush
/// Emits the current unterminated tail of a streaming child's stdout
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
/// to flush progress dots without waiting for a newline.
public func flushPartialLine(id: String) {
guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
}
if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail))
}
children[id] = child
}
// MARK: - Kill // MARK: - Kill
/// Terminates a child. The `exit` event still fires exactly once. /// Terminates a child. First runs any registered pre-kill hook, then
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7). /// drops stdin and signals the process. For streaming children the
public func kill(id: String) { /// `exit` event is emitted once both stdout and stderr EOFs have been
/// seen (or the watchdog finalizes). For captured children the real
/// exit code is emitted by `runCaptured` itself.
public func kill(id: String) async {
if let hook = preKillHooks.removeValue(forKey: id) {
await hook()
}
if var child = children[id] { if var child = children[id] {
try? child.stdin?.close() try? child.stdin?.close()
child.stdin = nil child.stdin = nil
children[id] = child children[id] = child
if child.process.isRunning { if child.process.isRunning {
child.process.terminate() child.process.terminate()
} else { } else if child.pendingExitCode == nil {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) } // The process already exited but `didTerminate` has not
// run; synthesize it so `maybeFinalize` can fire.
didTerminate(id: id, code: child.process.terminationStatus)
} }
return return
} }
if let process = captured[id] { if let process = captured[id] {
if process.isRunning { process.terminate() } if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil { // Do not emit `.exit` here; `runCaptured` emits the real code
emit(.exit(id: id, code: process.terminationStatus)) // after the process reaps.
} return
} }
} }
/// Terminates every running child; returns how many were signaled /// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149). /// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult @discardableResult
public func killAll() -> Int { public func killAll() async -> Int {
let ids = Array(children.keys) + Array(captured.keys) let ids = runningIDs
for id in ids { kill(id: id) } for id in ids { await kill(id: id) }
return ids.count return ids.count
} }
// MARK: - Force kill (SIGKILL fallback)
/// Sends `SIGKILL` to a streaming child if it is still running.
/// Used by the finalization watchdog when a graceful `terminate()`
/// does not cause the process to exit.
public func forceKill(id: String) {
guard let child = children[id],
!child.finalized,
child.process.isRunning
else { return }
let pid = child.process.processIdentifier
guard pid > 0 else { return }
_ = Darwin.kill(pid, SIGKILL)
}
// MARK: - Internals // MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] { private func childEnvironment(extra: [String: String]) -> [String: String] {
@@ -339,6 +480,16 @@ public actor ProcessManager {
child.pendingExitCode = code child.pendingExitCode = code
try? child.stdin?.close() try? child.stdin?.close()
child.stdin = nil child.stdin = nil
// Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
}
children[id] = child children[id] = child
maybeFinalize(id: id) maybeFinalize(id: id)
} }
@@ -351,8 +502,12 @@ public actor ProcessManager {
child.stdoutEOF, child.stderrEOF, child.stdoutEOF, child.stderrEOF,
!child.finalized !child.finalized
else { return } else { return }
child.finalized = true child.finalized = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children.removeValue(forKey: id) children.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
// Flush unterminated tail lines. // Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder), if var decoder = Optional(child.stdoutDecoder),
@@ -369,4 +524,20 @@ public actor ProcessManager {
} }
emit(.exit(id: id, code: code)) emit(.exit(id: id, code: code))
} }
/// Forces finalization even when one or both EOFs are missing.
/// Used by the `didTerminate` watchdog.
private func forceFinalize(id: String) {
guard var child = children[id], !child.finalized else { return }
if child.pendingExitCode == nil {
child.pendingExitCode = -9
}
child.stdoutEOF = true
child.stderrEOF = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children[id] = child
maybeFinalize(id: id)
}
} }
@@ -2,32 +2,40 @@ import Foundation
/// Computes a consecutive-breach warning from verification history. /// Computes a consecutive-breach warning from verification history.
/// ///
/// A drift alert triggers when there are at least two `poor` records on /// A drift alert triggers when the most recent chronologically consecutive
/// distinct calendar days, or two `poor` records at least one hour apart. /// poor records form a run of at least two, and the first and last of that
/// run are on distinct UTC days or at least one hour apart.
public enum DriftAlert { public enum DriftAlert {
/// Returns an alert message, or `nil` when no consecutive breach exists. /// Returns an alert message, or `nil` when no consecutive breach exists.
public static func compute(from records: [VerificationRecord]) -> String? { public static func compute(from records: [VerificationRecord]) -> String? {
let poor = records // Work in chronological order.
.filter { $0.status == .poor } let chronological = records.sorted { $0.timestamp < $1.timestamp }
.sorted { $0.timestamp < $1.timestamp }
guard poor.count >= 2 else { return nil } // Build the longest suffix of consecutive `.poor` records.
// Non-poor records break the run, so we stop at the first non-poor
for i in 0..<poor.count { // encountered from the end.
for j in (i + 1)..<poor.count { var run: [VerificationRecord] = []
let a = poor[i] for record in chronological.reversed() {
let b = poor[j] if record.status == .poor {
run.insert(record, at: 0)
let sameDay = Calendar.utc.isDate(a.timestamp, inSameDayAs: b.timestamp) } else {
let oneHour = b.timestamp.timeIntervalSince(a.timestamp) >= 3600 break
if !sameDay || oneHour {
return "Drift alert: poor results between \(a.id) and \(b.id)."
}
} }
} }
guard run.count >= 2 else { return nil }
let first = run.first!
let last = run.last!
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
if !sameDay || oneHour {
return "Drift alert: poor results between \(first.id) and \(last.id)."
}
return nil return nil
} }
} }
@@ -33,10 +33,32 @@ public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
/// Installs an ICC/ICM profile into the OS colour store. /// Installs an ICC/ICM profile into the OS colour store.
public enum ProfileInstaller { public enum ProfileInstaller {
/// Resolves the destination URL that `install` would write to for the
/// given source and options, without copying anything. Useful for
/// collision previews in the UI.
public static func resolveDestinationURL(
for config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> URL {
let sourceURL = config.sourceURL
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
try validateSourceURL(sourceURL)
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
}
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or /// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
/// `/Library/ColorSync/Profiles`. Always copies, never moves. /// `/Library/ColorSync/Profiles`. Always copies, never moves.
public static func install(config: InstallProfileConfig) throws -> InstallProfileResult { public static func install(
let fm = FileManager.default config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> InstallProfileResult {
let fm = fileManager
// Source validation. // Source validation.
let sourceURL = config.sourceURL let sourceURL = config.sourceURL
@@ -55,38 +77,37 @@ public enum ProfileInstaller {
throw ProfileInstallError.sourceTooSmall throw ProfileInstallError.sourceTooSmall
} }
// Stem security. try validateSourceURL(sourceURL)
let stem = sourceURL.deletingPathExtension().lastPathComponent
guard !stem.contains("..") && !stem.contains("/") && !stem.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
// Destination directory. // Destination directory.
let destDir: URL let destURL = try resolveDestinationURL(for: config, fileManager: fm)
if config.options.preferSystem { try? fm.createDirectory(
destDir = URL(fileURLWithPath: "/Library/ColorSync/Profiles") at: destURL.deletingLastPathComponent(),
} else { withIntermediateDirectories: true
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. // Collision resolution.
let destExists = fm.fileExists(atPath: destURL.path) let destExists = fm.fileExists(atPath: destURL.path)
if destExists { if destExists {
if config.options.forceOverwrite { if config.options.forceOverwrite {
// Continue to overwrite path. return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
fileManager: fm,
overwritten: true,
renamed: false
)
} else if config.options.collisionPolicy == .rename { } else if config.options.collisionPolicy == .rename {
let epoch = Int(Date().timeIntervalSince1970) let epoch = Int(Date().timeIntervalSince1970)
let renamedURL = destDir.appendingPathComponent("\(stem)-\(epoch).icc") let stem = sourceURL.deletingPathExtension().lastPathComponent
let renamedURL = destURL.deletingLastPathComponent()
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
return try performInstall( return try performInstall(
from: sourceURL, from: sourceURL,
to: renamedURL, to: renamedURL,
options: config.options, options: config.options,
fileManager: fm,
overwritten: false, overwritten: false,
renamed: true renamed: true
) )
@@ -102,19 +123,53 @@ public enum ProfileInstaller {
from: sourceURL, from: sourceURL,
to: destURL, to: destURL,
options: config.options, options: config.options,
overwritten: destExists, fileManager: fm,
overwritten: false,
renamed: false renamed: false
) )
} }
// MARK: - Private helpers
private static func validateSourceURL(_ sourceURL: URL) throws {
let path = sourceURL.path
let stem = sourceURL.deletingPathExtension().lastPathComponent
// Reject backslashes anywhere in the path.
guard !path.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
// Reject any path component that is literally "." or "..".
// This allows names like "foo..bar" while blocking real traversal.
for component in sourceURL.pathComponents {
if component == "." || component == ".." {
throw ProfileInstallError.unsafeStem(stem)
}
}
}
private static func destinationDirectory(
for options: InstallProfileOptions,
fileManager: FileManager
) -> URL {
if options.preferSystem {
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
return fileManager.homeDirectoryForCurrentUser
.appendingPathComponent("Library/ColorSync/Profiles")
}
}
private static func performInstall( private static func performInstall(
from sourceURL: URL, from sourceURL: URL,
to destURL: URL, to destURL: URL,
options: InstallProfileOptions, options: InstallProfileOptions,
fileManager: FileManager,
overwritten: Bool, overwritten: Bool,
renamed: Bool renamed: Bool
) throws -> InstallProfileResult { ) throws -> InstallProfileResult {
let fm = FileManager.default let fm = fileManager
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp") let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
// Remove stale tmp. // Remove stale tmp.
@@ -123,6 +178,13 @@ public enum ProfileInstaller {
do { do {
try fm.copyItem(at: sourceURL, to: tmpURL) try fm.copyItem(at: sourceURL, to: tmpURL)
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let tmpSize = attrs?[.size] as? UInt64 ?? 0
guard tmpSize >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ProfileInstallError.sourceTooSmall
}
if fm.fileExists(atPath: destURL.path) { if fm.fileExists(atPath: destURL.path) {
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL) _ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
} else { } else {
@@ -135,6 +197,10 @@ public enum ProfileInstaller {
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) { if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
throw ProfileInstallError.systemRequiresAdminRights throw ProfileInstallError.systemRequiresAdminRights
} }
if let installError = error as? ProfileInstallError {
throw installError
}
throw ProfileInstallError.copyFailed(error.localizedDescription) throw ProfileInstallError.copyFailed(error.localizedDescription)
} }
@@ -57,10 +57,12 @@ public actor VerificationHistoryStore {
/// Appends a record, trims to capacity, and writes atomically. /// Appends a record, trims to capacity, and writes atomically.
/// ///
/// Returns the trimmed list, or `nil` if a write error occurs so the /// Loads the existing history first and propagates any load error so an
/// caller can surface the failure without replacing the in-memory list. /// unparseable file is never overwritten.
@discardableResult @discardableResult
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] { public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
try load()
var updated = records var updated = records
updated.append(record) updated.append(record)
if updated.count > capacity { if updated.count > capacity {
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
import ICCeryCore
/// About dialog for ICCery (issue #31, docs/21 §Modals).
struct AboutView: View {
let onClose: () -> Void
private let info = ArtefactFiles.appInfo()
var body: some View {
VStack(spacing: 20) {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 64)
Text("ICCery")
.font(.title)
.foregroundStyle(Theme.text)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Version:")
.foregroundStyle(.secondary)
Text(info.version)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutVersion")
}
HStack {
Text("Build:")
.foregroundStyle(.secondary)
Text(info.build)
.foregroundStyle(Theme.text)
}
HStack {
Text("Build date:")
.foregroundStyle(.secondary)
Text(info.buildDate)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutBuildDate")
}
}
.font(.callout)
Text("Native macOS printer profiling workstation.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
onClose()
}
.controlSize(.large)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeAboutBtn")
}
.padding(32)
.frame(width: 360)
.background(Theme.panel)
.accessibilityIdentifier("aboutDialog")
}
}
+2
View File
@@ -68,6 +68,8 @@ enum UITestHooks {
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse). /// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
/// Preset import file. /// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination. /// Preset export destination.
+435
View File
@@ -0,0 +1,435 @@
import SwiftUI
import SceneKit
import ICCeryCore
import simd
/// Native SceneKit 3D gamut viewer.
///
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth.
struct GamutView: View {
@State private var viewModel: GamutViewModel
@FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
}
var body: some View {
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera
)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true }
VStack {
HStack {
Spacer()
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
Spacer()
HStack {
Text(viewModel.status)
.font(.caption)
.padding(8)
.background(.thinMaterial)
.cornerRadius(6)
.accessibilityIdentifier("gamutStatusText")
Spacer()
}
.padding(8)
}
}
.frame(minWidth: 500, minHeight: 400)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
}
}
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
/// one or two ``GamutMesh`` values.
///
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
var onReset: Binding<() -> Void>
func makeNSView(context: Context) -> SCNView {
let scnView = SCNView()
scnView.backgroundColor = NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1)
scnView.allowsCameraControl = true
scnView.showsStatistics = false
scnView.antialiasingMode = .multisampling4X
let scene = SCNScene()
scnView.scene = scene
scnView.autoenablesDefaultLighting = false
context.coordinator.scnView = scnView
context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator()
onReset.wrappedValue = { [weak coordinator] in
coordinator?.resetCamera()
}
return coordinator
}
@MainActor
final class Coordinator: NSObject {
weak var scnView: SCNView?
weak var scene: SCNScene?
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
private let axisNode = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
node.camera?.zFar = 2000
return node
}()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene else { return }
// Rebuild from scratch on every mesh change to avoid stale geometry.
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
scene.rootNode.addChildNode(axisNode)
scene.rootNode.addChildNode(profileNode)
scene.rootNode.addChildNode(referenceGroup)
scene.rootNode.addChildNode(cameraNode)
buildAxisScaffold()
if let profile {
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
} else {
profileNode.childNodes.forEach { $0.removeFromParentNode() }
}
if let reference {
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
referenceGroup.addChildNode(referenceMeshNode(reference))
}
addLights(to: scene)
resetCamera()
}
private func addLights(to scene: SCNScene) {
let ambient = SCNNode()
ambient.light = SCNLight()
ambient.light?.type = .ambient
ambient.light?.color = NSColor.white
ambient.light?.intensity = 750
scene.rootNode.addChildNode(ambient)
let key = SCNNode()
key.light = SCNLight()
key.light?.type = .directional
key.light?.color = NSColor.white
key.light?.intensity = 800
key.position = SCNVector3(150, 250, 150)
key.look(at: SCNVector3(0, 50, 0))
scene.rootNode.addChildNode(key)
let fill = SCNNode()
fill.light = SCNLight()
fill.light?.type = .directional
fill.light?.color = NSColor.white
fill.light?.intensity = 350
fill.position = SCNVector3(-120, -80, -120)
fill.look(at: SCNVector3(0, 50, 0))
scene.rootNode.addChildNode(fill)
}
private func buildAxisScaffold() {
axisNode.childNodes.forEach { $0.removeFromParentNode() }
// Bounding box: a*,b* ±128, L* 0100.
let box = buildWireBox(size: SIMD3<Float>(256, 100, 256), color: NSColor(red: 0.137, green: 0.137, blue: 0.212, alpha: 0.9))
box.position = SCNVector3(0, 50, 0)
axisNode.addChildNode(box)
// Ground grid at y=0.
axisNode.addChildNode(buildGridNode())
// Axis lines.
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(0, 0, 0),
to: SIMD3<Float>(0, 100, 0),
color: NSColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0)
))
let abAxisColor = NSColor(red: 0.6, green: 0.733, blue: 0.8, alpha: 1.0)
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(-128, 0, 0),
to: SIMD3<Float>(128, 0, 0),
color: abAxisColor
))
axisNode.addChildNode(buildLineNode(
from: SIMD3<Float>(0, 0, -128),
to: SIMD3<Float>(0, 0, 128),
color: abAxisColor
))
}
private func buildWireBox(size: SIMD3<Float>, color: NSColor) -> SCNNode {
let hx = size.x / 2
let hy = size.y / 2
let hz = size.z / 2
let corners: [SIMD3<Float>] = [
SIMD3(-hx, -hy, -hz), SIMD3(hx, -hy, -hz),
SIMD3(hx, -hy, hz), SIMD3(-hx, -hy, hz),
SIMD3(-hx, hy, -hz), SIMD3(hx, hy, -hz),
SIMD3(hx, hy, hz), SIMD3(-hx, hy, hz),
]
// 12 edges, two vertices each.
let edges: [(Int, Int)] = [
(0,1), (1,2), (2,3), (3,0),
(4,5), (5,6), (6,7), (7,4),
(0,4), (1,5), (2,6), (3,7),
]
var points: [SIMD3<Float>] = []
for (a, b) in edges {
points.append(corners[a])
points.append(corners[b])
}
return lineNode(points: points, color: color)
}
private func buildGridNode() -> SCNNode {
let divisions = 16
let half = Float(128)
let step = (half * 2) / Float(divisions)
var points: [SIMD3<Float>] = []
for i in 0...divisions {
let v = -half + step * Float(i)
// X-aligned
points.append(SIMD3(-half, 0, v))
points.append(SIMD3(half, 0, v))
// Z-aligned
points.append(SIMD3(v, 0, -half))
points.append(SIMD3(v, 0, half))
}
let gridColor = NSColor(red: 0.118, green: 0.118, blue: 0.157, alpha: 1.0)
return lineNode(points: points, color: gridColor)
}
private func buildLineNode(from: SIMD3<Float>, to: SIMD3<Float>, color: NSColor) -> SCNNode {
return lineNode(points: [from, to], color: color)
}
/// Builds a line-set from a flat list of point pairs.
///
/// Uses data-backed `SCNGeometrySource` so it works with `simd` vectors
/// and avoids the SceneKit convenience-initializer label mismatch.
private func lineNode(points: [SIMD3<Float>], color: NSColor) -> SCNNode {
let source = source(for: points)
let count = points.count
var indices: [UInt32] = []
indices.reserveCapacity(count)
for i in 0..<UInt32(count) {
indices.append(i)
}
let data = indices.withUnsafeBytes { Data($0) }
let element = SCNGeometryElement(
data: data,
primitiveType: .line,
primitiveCount: count / 2,
bytesPerIndex: 4
)
let geometry = SCNGeometry(sources: [source], elements: [element])
let material = SCNMaterial()
material.lightingModel = .constant
material.diffuse.contents = color
material.isDoubleSided = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.88
material.isDoubleSided = true
geometry.materials = [material]
let node = SCNNode(geometry: geometry)
node.name = name
return node
}
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
// Faint fill.
let fillMaterial = SCNMaterial()
fillMaterial.lightingModel = .lambert
fillMaterial.diffuse.contents = NSColor(red: 0.533, green: 0.6, blue: 0.733, alpha: 1.0)
fillMaterial.transparency = 0.93
fillMaterial.isDoubleSided = true
fillMaterial.writesToDepthBuffer = false
geometry.materials = [fillMaterial]
let fillNode = SCNNode(geometry: geometry)
// Structural outline: one line per triangle edge.
var linePoints: [SIMD3<Float>] = []
for face in mesh.faces {
let va = mesh.vertices[Int(face.a)].position
let vb = mesh.vertices[Int(face.b)].position
let vc = mesh.vertices[Int(face.c)].position
linePoints.append(va); linePoints.append(vb)
linePoints.append(vb); linePoints.append(vc)
linePoints.append(vc); linePoints.append(va)
}
let edgeColor = NSColor(red: 0.4, green: 0.533, blue: 0.667, alpha: 0.55)
let edgeNode = lineNode(points: linePoints, color: edgeColor)
let group = SCNNode()
group.addChildNode(fillNode)
group.addChildNode(edgeNode)
return group
}
/// Returns an `SCNGeometry` with per-vertex positions and sRGB colours.
///
/// Uses data-backed `SCNGeometrySource` initializers; this is the only
/// path that supports vertex colours through the `.color` semantic.
private func scnGeometry(for mesh: GamutMesh) -> (SCNGeometry, SCNGeometryElement) {
let positions = mesh.vertices.map { $0.position }
let positionData = positions.withUnsafeBytes { Data($0) }
let positionSource = SCNGeometrySource(
data: positionData,
semantic: .vertex,
vectorCount: positions.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD3<Float>>.stride
)
let colors: [SIMD4<Float>] = mesh.vertices.map { v in
SIMD4<Float>(Float(v.rgb.r), Float(v.rgb.g), Float(v.rgb.b), 1.0)
}
let colorData = colors.withUnsafeBytes { Data($0) }
let colorSource = SCNGeometrySource(
data: colorData,
semantic: .color,
vectorCount: colors.count,
usesFloatComponents: true,
componentsPerVector: 4,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD4<Float>>.stride
)
var indices: [UInt32] = []
indices.reserveCapacity(mesh.faces.count * 3)
for face in mesh.faces {
indices.append(face.a)
indices.append(face.b)
indices.append(face.c)
}
let data = indices.withUnsafeBytes { Data($0) }
let element = SCNGeometryElement(
data: data,
primitiveType: .triangles,
primitiveCount: mesh.faces.count,
bytesPerIndex: 4
)
let geometry = SCNGeometry(sources: [positionSource, colorSource], elements: [element])
return (geometry, element)
}
/// Shared helper for data-backed position sources.
private func source(for points: [SIMD3<Float>]) -> SCNGeometrySource {
let data = points.withUnsafeBytes { Data($0) }
return SCNGeometrySource(
data: data,
semantic: .vertex,
vectorCount: points.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SIMD3<Float>>.stride
)
}
func resetCamera() {
guard let scnView else { return }
// Re-create the camera node so `allowsCameraControl` starts from the
// canonical home position every time.
let newCameraNode = SCNNode()
newCameraNode.camera = SCNCamera()
newCameraNode.camera?.zFar = 2000
let eye = SIMD3<Float>(180, 120, 180)
let target = SIMD3<Float>(0, 50, 0)
newCameraNode.simdTransform = lookAt(eye: eye, target: target, up: SIMD3<Float>(0, 1, 0))
if let scene = scnView.scene, scene.rootNode.childNodes.contains(cameraNode) {
cameraNode.removeFromParentNode()
}
scnView.scene?.rootNode.addChildNode(newCameraNode)
scnView.pointOfView = newCameraNode
}
private func lookAt(eye: SIMD3<Float>, target: SIMD3<Float>, up: SIMD3<Float>) -> simd_float4x4 {
let forward = normalize(target - eye)
let right = normalize(cross(up, forward))
let newUp = cross(forward, right)
var matrix = simd_float4x4()
matrix.columns.0 = SIMD4<Float>(right, 0)
matrix.columns.1 = SIMD4<Float>(newUp, 0)
matrix.columns.2 = SIMD4<Float>(-forward, 0)
matrix.columns.3 = SIMD4<Float>(eye, 1)
return matrix
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer.
///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory.
@MainActor
@Observable
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh?
/// User-facing status line.
var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {}
private let profileGamURL: URL?
init(profileGamURL: URL? = nil) {
self.profileGamURL = profileGamURL
Task { await load() }
}
private func load() async {
do {
let referenceURL = BinaryResolver().referenceGamut("sRGB")
let reference = try await parse(url: referenceURL)
sRGBMesh = reference
if let profileGamURL {
let profile = try await parse(url: profileGamURL)
profileMesh = profile
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
} else {
status = "sRGB reference gamut (\(reference.faces.count) faces)"
}
} catch {
status = "Could not load gamut: \(error.localizedDescription)"
}
}
/// Parses a `.gam` file off the main actor so large meshes do not stall
/// the UI.
private func parse(url: URL) async throws -> GamutMesh {
try await Task.detached {
try GamutMeshParser.parse(url: url)
}.value
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
/// Reusable help overlay badge that does not reflow layout (#171).
///
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
/// available on hover, so the overlay is purely a visual cue in help mode.
struct HelpOverlay: ViewModifier {
let text: String
@Binding var showing: Bool
func body(content: Content) -> some View {
content
.help(text)
.overlay(alignment: .topTrailing) {
if showing {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.accent)
.offset(x: 8, y: -8)
}
}
}
}
extension View {
/// Adds a non-reflowing help overlay to the view.
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
modifier(HelpOverlay(text: text, showing: showing))
}
}
@@ -305,6 +305,10 @@ final class MeasurementWorkflowViewModel {
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY) environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
chartreadTask?.cancel() chartreadTask?.cancel()
isChartreadRunning = false isChartreadRunning = false
chartreadState = .idle
currentPrompt = nil
requestedWarningKey = nil
showRemoveSheetNotice = false
} }
func sendWarningKey(_ key: String) { func sendWarningKey(_ key: String) {
+54 -26
View File
@@ -52,6 +52,8 @@ final class ProfileWorkflowViewModel {
var colprofProgress: String? var colprofProgress: String?
var lastError: String? var lastError: String?
var createdProfileURL: URL? var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24) // MARK: - Stage 4/5 calibration (issue #24)
@@ -81,6 +83,20 @@ final class ProfileWorkflowViewModel {
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
self.environment = environment self.environment = environment
restoreCreatedProfileURL()
}
/// Restores `createdProfileURL` and `createdGamutURL` from the wizard
/// artefacts or by probing the working directory (#52, #28).
func restoreCreatedProfileURL() {
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
createdProfileURL = wizard.artefacts.profilePath
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
createdGamutURL = wizard.artefacts.gamPath
?? ArtefactProbe.artefact(wizard.basename, "gam", cwd)
if let gam = createdGamutURL, !FileManager.default.fileExists(atPath: gam.path) {
createdGamutURL = nil
}
} }
// MARK: - Derived // MARK: - Derived
@@ -181,6 +197,7 @@ final class ProfileWorkflowViewModel {
colprofProgress = nil colprofProgress = nil
lastError = nil lastError = nil
createdProfileURL = nil createdProfileURL = nil
createdGamutURL = nil
let runner = environment.runner let runner = environment.runner
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -206,6 +223,7 @@ final class ProfileWorkflowViewModel {
calibrationPath: self.calibrationFile, calibrationPath: self.calibrationFile,
inputProfileURL: url inputProfileURL: url
) )
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig) finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)") self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
} }
@@ -213,12 +231,13 @@ final class ProfileWorkflowViewModel {
// Gamut extraction is best-effort for Stage 5 / M6 viewer. // Gamut extraction is best-effort for Stage 5 / M6 viewer.
do { do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL) let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch) self?.colprofLog.append(contentsOf: batch)
} }
} }
self.colprofLog.append("Gamut mesh extracted.") self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch { } catch {
self.wizard.showNotice( self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)", "Gamut extraction skipped: \(error.localizedDescription)",
@@ -256,7 +275,15 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 5: verify profile // MARK: - Stage 5: verify profile
var knownPrinters: [String] { var knownPrinters: [String] {
Array(Set(verificationHistory.map { $0.printerName })).sorted() var names = Set<String>()
for record in verificationHistory {
if record.printerName.isEmpty {
names.insert("Unknown")
} else {
names.insert(record.printerName)
}
}
return Array(names).sorted()
} }
func loadHistory() { func loadHistory() {
@@ -333,10 +360,11 @@ final class ProfileWorkflowViewModel {
let timestamp = Date() let timestamp = Date()
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())" let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
return VerificationRecord( return VerificationRecord(
id: id, id: id,
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename, profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
printerName: wizard.printerName ?? "", printerName: printerName,
avgDE: avg, avgDE: avg,
maxDE: max, maxDE: max,
rmsDE: rms, rmsDE: rms,
@@ -381,24 +409,32 @@ final class ProfileWorkflowViewModel {
openColorPanel: settings.openColorPanelAfterInstall openColorPanel: settings.openColorPanelAfterInstall
) )
let destURL = installDestination(for: sourceURL, options: options) do {
let collision = FileManager.default.fileExists(atPath: destURL.path) let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
let destURL = try ProfileInstaller.resolveDestinationURL(for: config)
let collision = FileManager.default.fileExists(atPath: destURL.path)
if collision && settings.askBeforeOverwriteProfile { if collision && settings.askBeforeOverwriteProfile {
pendingInstallOptions = options pendingInstallOptions = options
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists." installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
showingInstallCollision = true showingInstallCollision = true
return return
}
runInstall(sourceURL: sourceURL, options: options)
} catch {
wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
} }
runInstall(sourceURL: sourceURL, options: options)
} }
func resolveInstallCollision(policy: ProfileCollisionPolicy) { func resolveInstallCollision(policy: ProfileCollisionPolicy) {
showingInstallCollision = false showingInstallCollision = false
guard let sourceURL = createdProfileURL, guard let sourceURL = createdProfileURL,
var options = pendingInstallOptions else { return } var options = pendingInstallOptions else { return }
options.collisionPolicy = policy
if policy == .cancel { if policy == .cancel {
installResult = InstallProfileResult( installResult = InstallProfileResult(
destPath: "", destPath: "",
@@ -410,20 +446,12 @@ final class ProfileWorkflowViewModel {
) )
return return
} }
runInstall(sourceURL: sourceURL, options: options)
}
private func installDestination(for sourceURL: URL, options: InstallProfileOptions) -> URL { options.collisionPolicy = policy
let stem = sourceURL.deletingPathExtension().lastPathComponent if policy == .overwrite {
let fm = FileManager.default options.forceOverwrite = true
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") runInstall(sourceURL: sourceURL, options: options)
} }
private func runInstall(sourceURL: URL, options: InstallProfileOptions) { private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
+11 -5
View File
@@ -8,6 +8,7 @@ struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard } private var model: WizardViewModel { workflow.wizard }
@@ -16,7 +17,8 @@ struct RootView: View {
SidebarView( SidebarView(
workflow: workflow, workflow: workflow,
onOpenSettings: { showingSettings = true }, onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true } onOpenAbout: { showingAbout = true },
showingAllHelp: $showingAllHelp
) )
Rectangle() Rectangle()
@@ -48,10 +50,14 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) { .sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow) ManagePresetsDialog(workflow: workflow)
} }
.alert("ICCery 2.0.0", isPresented: $showingAbout) { .sheet(isPresented: $showingAbout) {
Button("OK") {} AboutView { showingAbout = false }
} message: { }
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") .sheet(isPresented: Binding(
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
} }
} }
+19 -2
View File
@@ -7,6 +7,7 @@ struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel @Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard } private var model: WizardViewModel { workflow.wizard }
@@ -22,12 +23,20 @@ struct SidebarView: View {
Image(systemName: "gearshape") Image(systemName: "gearshape")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Settings") .helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openSettingsBtn")
Button(action: onOpenAbout) { Button(action: onOpenAbout) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("About ICCery") .helpOverlay("Open the About dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openAboutBtn")
Button(action: { showingAllHelp.toggle() }) {
Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle")
}
.buttonStyle(.plain)
.help("Toggle help overlays")
.accessibilityIdentifier("btnToggleAllHelp")
} }
.padding(12) .padding(12)
@@ -75,6 +84,14 @@ struct SidebarView: View {
.disabled(true) .disabled(true)
.padding(.horizontal, 12) .padding(.horizontal, 12)
Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) {
Label("View Gamut", systemImage: "view.3d")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
Divider().overlay(Theme.border) Divider().overlay(Theme.border)
.padding(.vertical, 8) .padding(.vertical, 8)
+1 -2
View File
@@ -87,9 +87,8 @@ struct Stage1View: View {
Button("Working Dir…") { workflow.browseForWorkingDirectory() } Button("Working Dir…") { workflow.browseForWorkingDirectory() }
Button("Open Existing…") { workflow.openExistingTarget() } Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting") .accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { /* CGATS import #94, later */ } Button("Import Dataset…") { workflow.importMeasurementDataset() }
.accessibilityIdentifier("btn-import-dataset") .accessibilityIdentifier("btn-import-dataset")
.disabled(true)
} }
Text(workflow.targetDirectory?.path ?? "No working directory selected") Text(workflow.targetDirectory?.path ?? "No working directory selected")
.font(.caption) .font(.caption)
+5 -12
View File
@@ -210,7 +210,9 @@ struct Stage3View: View {
.accessibilityIdentifier("btnCalibrate") .accessibilityIdentifier("btnCalibrate")
case .awaitingStrip: case .awaitingStrip:
Button("Trigger") { model.calibrate() } Button("Trigger") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate") .accessibilityIdentifier("btnTrigger")
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneReadEarly")
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning: case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
Button(continueTitle) { model.accept() } Button(continueTitle) { model.accept() }
.accessibilityIdentifier("btnAccept") .accessibilityIdentifier("btnAccept")
@@ -224,16 +226,6 @@ struct Stage3View: View {
EmptyView() EmptyView()
} }
if model.chartreadState == .awaitingStrip || model.chartreadState == .allStripsRead {
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneRead")
}
if model.chartreadState == .error {
Button("Retry") { model.retry() }
.accessibilityIdentifier("btnRetry")
}
Button("Cancel") { model.cancelRead() } Button("Cancel") { model.cancelRead() }
.accessibilityIdentifier("btnCancel") .accessibilityIdentifier("btnCancel")
} }
@@ -374,7 +366,7 @@ struct Stage3View: View {
Button("Finish & Average") { Button("Finish & Average") {
model.finishAndAverage() model.finishAndAverage()
} }
.disabled(!model.isFinished || model.isFinishing) .disabled(!model.canFinish || model.isFinishing)
.accessibilityIdentifier("btnFinishAndAverage") .accessibilityIdentifier("btnFinishAndAverage")
} }
@@ -386,6 +378,7 @@ struct Stage3View: View {
} }
.padding(16) .padding(16)
.background(Theme.panel) .background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("chartreadAveragingPanel") .accessibilityIdentifier("chartreadAveragingPanel")
} }
} }
+1
View File
@@ -18,6 +18,7 @@ struct Stage4View: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background) .background(Theme.background)
.onAppear { model.restoreCreatedProfileURL() }
} }
// MARK: - Header // MARK: - Header
+11 -2
View File
@@ -21,7 +21,10 @@ struct Stage5View: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background) .background(Theme.background)
.onAppear { model.loadHistory() } .onAppear {
model.restoreCreatedProfileURL()
model.loadHistory()
}
.alert("Install profile", isPresented: $model.showingInstallCollision) { .alert("Install profile", isPresented: $model.showingInstallCollision) {
Button("Overwrite", role: .destructive) { Button("Overwrite", role: .destructive) {
model.resolveInstallCollision(policy: .overwrite) model.resolveInstallCollision(policy: .overwrite)
@@ -70,7 +73,7 @@ struct Stage5View: View {
.accessibilityIdentifier("driftAlert") .accessibilityIdentifier("driftAlert")
} }
if let warning = model.profcheckWarning, !warning.isEmpty, model.driftAlert == nil { if let warning = model.profcheckWarning, !warning.isEmpty {
Text("\(warning)") Text("\(warning)")
.font(.caption) .font(.caption)
.padding(.horizontal, 8) .padding(.horizontal, 8)
@@ -143,6 +146,12 @@ struct Stage5View: View {
Spacer() Spacer()
Button("View Gamut") {
model.wizard.openGamut(profileGamURL: model.createdGamutURL)
}
.disabled(model.createdGamutURL == nil)
.accessibilityIdentifier("btnViewGamut")
Button("Install Profile") { model.beginInstallProfile() } Button("Install Profile") { model.beginInstallProfile() }
.disabled(model.createdProfileURL == nil) .disabled(model.createdProfileURL == nil)
.accessibilityIdentifier("btnInstallProfile") .accessibilityIdentifier("btnInstallProfile")
@@ -243,6 +243,43 @@ final class TargetWorkflowViewModel {
// MARK: - Issue 8: resume an existing target // MARK: - Issue 8: resume an existing target
/// `#btn-import-dataset` open a measured dataset, write a canonical
/// `.ti3` to the working directory, and set the target (issue #30).
func importMeasurementDataset() {
let url = UITestHooks.isEnabled
? UITestHooks.datasetImportURL
: fileDialogs.selectDatasetFile()
guard let url else { return }
do {
let dataset = try CGATSParser.parse(url: url)
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
wizard.showNotice("Choose a working directory before importing.", kind: .warning)
return
}
let stem = url.deletingPathExtension().lastPathComponent
let output = directory.appendingPathComponent("\(stem).ti3")
try CGATSWriter.write(dataset, to: output)
wizard.setTarget(basename: stem, workingDirectory: directory)
wizard.refreshGating()
wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)")
if wizard.isUnlocked(.verifyInstall) {
wizard.go(to: .verifyInstall)
} else if wizard.isUnlocked(.buildProfile) {
wizard.go(to: .buildProfile)
} else {
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
}
} catch let error as CGATSParseError {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
} catch {
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
}
}
/// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103). /// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but /// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds. /// only when the sibling `.ti1` exists so the artefact gate holds.
+10
View File
@@ -42,6 +42,10 @@ final class WizardViewModel {
var notice: Notice? var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts() private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -126,6 +130,12 @@ final class WizardViewModel {
stage = .generate stage = .generate
} }
/// Open the 3D gamut viewer (issue #28).
func openGamut(profileGamURL: URL? = nil) {
self.gamutProfileURL = profileGamURL
showingGamutViewer = true
}
/// Window-focus hook (#151): files deleted in Finder re-lock stages. /// Window-focus hook (#151): files deleted in Finder re-lock stages.
/// If the current stage re-locked, fall back to the deepest unlocked. /// If the current stage re-locked, fall back to the deepest unlocked.
func windowDidBecomeKey() { func windowDidBecomeKey() {
@@ -15,16 +15,16 @@ struct ApplycalArgsTests {
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"]) #expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
@Test("Unapply is never sent from build") @Test("Unapply is emitted when the caller explicitly sets it")
func unapplyNotEmitted() throws { func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig( let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal", calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"), inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
unapply: true unapply: true
) )
let args = try ApplycalArgs.build(config: config) let args = try ApplycalArgs.build(config: config)
// Builder intentionally emits -u because config can set it, but // Builder emits -u only when the caller explicitly sets unapply.
// the UI layer never passes unapply: true in v2.0. // The UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"]) #expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
} }
} }
@@ -0,0 +1,134 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
private static let canonicalCTI3 = """
CTI3
DESCRIPTOR "Sample target"
COLOR_REP "RGB"
DEVICE_CLASS "DISPLAY"
NUMBER_OF_FIELDS 11
NUMBER_OF_SETS 2
BEGIN_DATA_FORMAT
SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B
END_DATA_FORMAT
BEGIN_DATA
1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0
2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y
END_DATA_FORMAT
BEGIN_DATA
1 255 128 0 50 25
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L
END_DATA_FORMAT
BEGIN_DATA
1 50 50 50 50 50
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
}
@Test("Rejects malformed arity")
func rejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
NUMBER_OF_SETS 1
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
BEGIN_DATA
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
}
}
+43 -1
View File
@@ -38,7 +38,7 @@ struct DriftAlertTests {
#expect(DriftAlert.compute(from: [day1, day2]) != nil) #expect(DriftAlert.compute(from: [day1, day2]) != nil)
} }
@Test("Non-poor results do not trigger") @Test("Non-poor records do not trigger")
func nonPoor() { func nonPoor() {
let records = [ let records = [
record(avg: 1.0, at: 0), record(avg: 1.0, at: 0),
@@ -47,6 +47,48 @@ struct DriftAlertTests {
#expect(DriftAlert.compute(from: records) == nil) #expect(DriftAlert.compute(from: records) == nil)
} }
@Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first
record(avg: 1.0, at: 20000), // good breaks the run
record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day
]
#expect(DriftAlert.compute(from: records) == nil)
}
@Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous
]
#expect(DriftAlert.compute(from: records) != nil)
}
@Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord { private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
VerificationRecord( VerificationRecord(
id: "vr-\(Int(offset))", id: "vr-\(Int(offset))",
@@ -0,0 +1,166 @@
import Foundation
import Testing
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
let bundle = Bundle.main
let resource = bundle.resourceURL ?? bundle.bundleURL
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
}
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 4
BEGIN_DATA
100 10.0 20.0 30.0
50 20.0 30.0 40.0
2 30.0 40.0 50.0
7 40.0 50.0 60.0
END_DATA
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 1 2
1 2 3
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
}
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 10.0 20.0 30.0
# inline comment
1 20.0 30.0 40.0
END_DATA
# another comment
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 1 0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
}
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 50.0 -20.0 80.0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
}
@Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 1
BEGIN_DATA
0 100.0 0.0 0.0
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
}
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 10.0 0.0 0.0
1 20.0 0.0 0.0
END_DATA
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 2
BEGIN_DATA
0 1 0
0 1 99
END_DATA
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1)
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
}
}
@Test("Throws when file is missing")
func throwsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
}
}
}
+140 -44
View File
@@ -2,42 +2,165 @@ import Foundation
import Testing import Testing
@testable import ICCeryCore @testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the
/// user home, so `ProfileInstaller` can be tested without writing to the
/// real `~/Library/ColorSync/Profiles`.
private final class TestFileManager: FileManager {
let tempHome: URL
init(home: URL) {
self.tempHome = home
super.init()
}
override var homeDirectoryForCurrentUser: URL {
tempHome
}
}
@Suite("ProfileInstaller") @Suite("ProfileInstaller")
struct ProfileInstallerTests { struct ProfileInstallerTests {
@Test("Copies .icc to user ColorSync folder") private func makeTempDir() throws -> URL {
func userInstall() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}
let source = tmp.appendingPathComponent("test.icc") private func makeSource(
let iccData = Data(repeating: 0, count: 256) at dir: URL,
try iccData.write(to: source) name: String,
bytes: [UInt8] = Array(repeating: 0, count: 256)
) throws -> URL {
let url = dir.appendingPathComponent(name)
let data = Data(bytes)
try data.write(to: url)
return url
}
let colorsync = tmp.appendingPathComponent("Library/ColorSync/Profiles") @Test("Installs .icc to user ColorSync folder")
func userInstall() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "test.icc")
// Inject a user profile install by replacing the home directory let result = try ProfileInstaller.install(
// is not practical; instead exercise Core validation on a config: InstallProfileConfig(sourceURL: source),
// temp-only path via the file URL safety checks and the public fileManager: testFM
// install against a writable system-like path is tested below. )
#expect(result.registered)
#expect(!result.overwritten)
#expect(!result.renamed)
#expect(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
@Test("Overwrite succeeds and replaces the existing file")
func overwriteSucceeds() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icc", bytes: (0..<256).map { UInt8($0) })
// First install.
let first = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(!first.overwritten)
// Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
try Data(newBytes).write(to: source)
let options = InstallProfileOptions(
forceOverwrite: true,
preferSystem: false,
collisionPolicy: .overwrite,
openColorPanel: false
)
let second = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source, options: options),
fileManager: testFM
)
#expect(second.overwritten)
#expect(!second.renamed)
#expect(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
#expect(Array(installed) == newBytes)
}
@Test("Preserves .icm source extension")
func preservesIcmExtension() throws {
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm")
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm"))
}
@Test("Rejects parent traversal in source path")
func rejectsParentTraversal() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
// Create a real file in the parent of `tmp` with a path that contains
// a literal ".." component.
let parent = tmp.deletingLastPathComponent()
let naughtyName = "naughty-\(UUID().uuidString).icc"
let realFile = parent.appendingPathComponent(naughtyName)
_ = try makeSource(at: parent, name: naughtyName)
defer { try? fm.removeItem(at: realFile) }
let sourceURL = tmp
.appendingPathComponent("..")
.appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path))
// 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 { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: unsafe)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error") Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") } if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch { } catch {
Issue.record("Unexpected error type: \(error)") Issue.record("Unexpected error type: \(error)")
} }
}
@Test("Allows stems with consecutive dots like foo..bar")
func allowsDoubleDotStem() throws {
let fm = FileManager.default
let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "foo..bar.icc")
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#expect(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath))
}
@Test("Rejects source files that are too small")
func rejectsSmallSource() throws {
let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source)
let small = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: small)
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: small)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error") Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") } if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
@@ -45,31 +168,4 @@ struct ProfileInstallerTests {
Issue.record("Unexpected error type: \(error)") 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))
}
} }
@@ -51,6 +51,86 @@ struct VerificationHistoryStoreTests {
} }
} }
@Test("Append loads existing records first")
func appendLoadsExisting() 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")
// Pre-populate the store on disk.
let existing = VerificationRecord(
id: "vr-existing",
profileName: "p",
printerName: "",
avgDE: 1.0,
maxDE: 1.0,
rmsDE: 1.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
let store1 = VerificationHistoryStore(url: url)
_ = try await store1.append(existing)
// A fresh store appending a new record must keep the existing one.
let store2 = VerificationHistoryStore(url: url)
let new = VerificationRecord(
id: "vr-new",
profileName: "p",
printerName: "",
avgDE: 2.0,
maxDE: 2.0,
rmsDE: 2.0,
patchCount: 2,
status: .good,
timestamp: Date(timeIntervalSince1970: 10)
)
_ = try await store2.append(new)
let all = await store2.all()
#expect(all.count == 2)
#expect(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" })
}
@Test("Append does not overwrite an unparseable file")
func appendPreservesUnparseableFile() 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")
let badJSON = "not json"
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
let store = VerificationHistoryStore(url: url)
let record = VerificationRecord(
id: "vr-new",
profileName: "p",
printerName: "",
avgDE: 1.0,
maxDE: 1.0,
rmsDE: 1.0,
patchCount: 1,
status: .good,
timestamp: Date(timeIntervalSince1970: 0)
)
do {
_ = try await store.append(record)
Issue.record("append() should propagate the load error")
} catch {
#expect(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON)
} else {
Issue.record("Could not read preserved file")
}
}
}
@Test("CSV export quoting") @Test("CSV export quoting")
func csvQuoting() async throws { func csvQuoting() async throws {
let fm = FileManager.default let fm = FileManager.default
@@ -0,0 +1,72 @@
import XCTest
/// About and help chrome UI tests (issue #31).
@MainActor
final class AboutHelpUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() async throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchEnvironment = ["ICCERY_UI_TESTING": "1"]
}
override func tearDown() async throws {
app?.terminate()
app = nil
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> 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
}
func testAboutDialogShowsVersionAndBuildDate() throws {
app.launch()
app.activate()
let openAbout = app.buttons["openAboutBtn"]
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
_ = waitFor("aboutDialog", timeout: 10)
XCTAssertTrue(element("aboutVersion").exists)
XCTAssertTrue(element("aboutBuildDate").exists)
let close = app.buttons["closeAboutBtn"]
XCTAssertTrue(close.exists)
close.click()
XCTAssertFalse(element("aboutDialog").exists)
}
func testHelpOverlaysDoNotChangeSidebarHeight() throws {
app.launch()
app.activate()
let toggle = app.buttons["btnToggleAllHelp"]
XCTAssertTrue(toggle.waitForExistence(timeout: 10))
let sidebar = app.groups.containing(.button, identifier: "openSettingsBtn").element
let before = sidebar.frame
toggle.click()
let after = sidebar.frame
XCTAssertEqual(before.size.height, after.size.height,
"Toggling global help must not reflow the sidebar height.")
XCTAssertTrue(app.descendants(matching: .any)["openSettingsBtn"].exists)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ def main():
def read_input(): def read_input():
line = read_line() line = read_line()
if not line: if line == "":
sys.exit(1) sys.exit(1)
return line.strip() return line.strip()
+6 -2
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Mock iccgamut for Milestone 5 UI tests. # Mock iccgamut for Milestone 5/6 UI tests.
# Writes {stem}.gam next to the profile path. # Writes {stem}.gam next to the profile path.
last="" last=""
for arg in "$@"; do last="$arg"; done for arg in "$@"; do last="$arg"; done
@@ -9,5 +9,9 @@ if [ "${ICCERY_MOCK_ICCGAMUT_EXIT:-0}" -ne 0 ]; then
fi fi
stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//') stem=$(basename "$last" | sed 's/\.icc$//; s/\.icm$//')
dir=$(dirname "$last") dir=$(dirname "$last")
touch "$dir/$stem.gam" if [ -n "${ICCERY_MOCK_GAMUT_SOURCE}" ] && [ -f "${ICCERY_MOCK_GAMUT_SOURCE}" ]; then
cp "${ICCERY_MOCK_GAMUT_SOURCE}" "$dir/$stem.gam"
else
touch "$dir/$stem.gam"
fi
exit 0 exit 0
+6 -5
View File
@@ -93,7 +93,6 @@ final class Milestone4UITests: XCTestCase {
/// End-to-end handheld chartread with the mock fixture produces a /// End-to-end handheld chartread with the mock fixture produces a
/// canonical .ti3 and unlocks Stage 4. /// canonical .ti3 and unlocks Stage 4.
func testHandheldFixtureChartreadAndAverage() throws { func testHandheldFixtureChartreadAndAverage() throws {
try XCTSkipIf(true, "Full interactive chartread UI requires fixture timing tuning; skipped for CI stability. Core chartread/arteffact tests cover the model.")
reachStage3() reachStage3()
app.buttons["btnDetectInstruments"].click() app.buttons["btnDetectInstruments"].click()
@@ -113,19 +112,21 @@ final class Milestone4UITests: XCTestCase {
app.buttons["btnCalibrate"].click() app.buttons["btnCalibrate"].click()
// Trigger strip A. // Trigger strip A.
_ = waitFor("btnCalibrate", timeout: 20) _ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnCalibrate"].click() app.buttons["btnTrigger"].click()
// Trigger strip B. // Trigger strip B.
_ = waitFor("btnCalibrate", timeout: 20) _ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnCalibrate"].click() app.buttons["btnTrigger"].click()
// All strips read Done & Save appears. // All strips read Done & Save appears.
_ = waitFor("btnDoneRead", timeout: 20) _ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click() app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot. // Averaging panel appears with one pass snapshot.
_ = waitFor("chartreadAveragingPanel", timeout: 20)
_ = waitFor("passCounterBadge", timeout: 20) _ = waitFor("passCounterBadge", timeout: 20)
XCTAssertTrue(app.buttons["btnFinishAndAverage"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["btnFinishAndAverage"].isEnabled) XCTAssertTrue(app.buttons["btnFinishAndAverage"].isEnabled)
app.buttons["btnFinishAndAverage"].click() app.buttons["btnFinishAndAverage"].click()
@@ -0,0 +1,64 @@
import Foundation
import XCTest
/// Milestone 6 CGATS import UI tests (issue #30).
@MainActor
final class Milestone6CGATSUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var datasetURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-cgats-ui-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
datasetURL = testRoot.appendingPathComponent("imported.csv")
try csv.write(to: datasetURL, atomically: true, encoding: .utf8)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_WORKDIR": testRoot.path,
"ICCERY_TEST_DATASET_IMPORT": datasetURL.path
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
}
/// `Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1`
/// Must fail if import presents a save panel or a `.ti1` filter.
func testImportUsesOpenPanelNotSaveTi1() throws {
app.launch()
app.activate()
XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10))
app.buttons["btn-import-dataset"].click()
// No save panel should appear; the open panel is stubbed under UI testing.
let savePanel = app.sheets.firstMatch
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.
_ = 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")
XCTAssertTrue(FileManager.default.fileExists(atPath: ti3URL.path))
}
}
@@ -0,0 +1,118 @@
import Foundation
import XCTest
/// Milestone 6 Issue #28 native SceneKit gamut viewer acceptance tests.
@MainActor
final class Milestone6GamutUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var appDataDir: URL!
private var referenceGamutURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui-m6-gamut-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
appDataDir = testRoot.appendingPathComponent("AppData")
// The bundled sRGB reference used by the app; copied into the test workdir
// by the mock iccgamut so the profile gamut is a real, parseable mesh.
referenceGamutURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Resources/Argyll/reference_gamuts/sRGB.gam")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: appDataDir, withIntermediateDirectories: true)
// Pre-stage a measured .ti3 and start the wizard 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,
"ICCERY_MOCK_GAMUT_SOURCE": referenceGamutURL.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> 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
}
/// Build and verify the mock profile, then open the native gamut viewer.
/// The viewer should load both the reference sRGB mesh and the profile
/// gamut copied from that reference.
func testViewGamutOpensSceneKitSheet() throws {
app.launch()
if !app.wait(for: .runningForeground, timeout: 10) {
app.activate()
}
waitFor("btnCreateProfile").click()
waitFor("btnVerifyProfile").click()
waitFor("btnViewGamut").click()
let gamutView = waitFor("gamutView")
XCTAssertTrue(gamutView.exists)
let status = waitFor("gamutStatusText")
let value = status.value as? String ?? ""
XCTAssertTrue(value.contains("faces"), "Gamut status should report mesh faces, got: \(value)")
// The reset button demonstrates that the viewer is interactive.
let reset = waitFor("btnResetGamutCamera")
XCTAssertTrue(reset.isEnabled)
reset.click()
}
}
+1
View File
@@ -25,6 +25,7 @@ targets:
dependencies: dependencies:
- package: ICCeryCore - package: ICCeryCore
product: ICCeryCore product: ICCeryCore
- sdk: SceneKit.framework
postBuildScripts: postBuildScripts:
- name: Copy Argyll sidecars - name: Copy Argyll sidecars
script: | script: |