Compare commits

..
Author SHA1 Message Date
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
32 changed files with 1750 additions and 241 deletions
@@ -87,6 +87,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -121,6 +122,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -169,6 +171,19 @@ public struct ArgyllRunner: Sendable {
// 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 {
var exitCode: Int32?
var stdout: String
@@ -179,10 +194,15 @@ public struct ArgyllRunner: Sendable {
/// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for
/// 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(
id processId: String,
events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)?
onLogBatch: (@Sendable ([String]) -> Void)?,
flushPartialLines: Bool = false
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
@@ -198,6 +218,17 @@ public struct ArgyllRunner: Sendable {
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 {
guard event.id == processId else { continue }
switch event {
@@ -229,6 +260,12 @@ public struct ArgyllRunner: Sendable {
break
}
}
dotFlushTask?.cancel()
if let dotFlushTask {
_ = await dotFlushTask.value
}
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
}
@@ -242,6 +279,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("instlist")
let processId = ProcessID.instlist
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -301,6 +339,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -335,6 +374,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -342,7 +382,12 @@ public struct ArgyllRunner: Sendable {
arguments: args,
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 {
throw ArgyllRunnerError.colprofFailed(
@@ -368,10 +413,13 @@ public struct ArgyllRunner: Sendable {
///
/// Runs `applycal` captured and performs an in-place replace via
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
/// file is removed and the original is left untouched.
/// file is removed and the original is left untouched. The UI must
/// never request `unapply` (#52).
public func runApplycal(
config: ApplycalConfig
) async throws -> URL {
assert(!config.unapply, "runApplycal does not support unapply")
let inputURL = config.inputProfileURL
let cwd = inputURL.deletingLastPathComponent()
let binaryURL = binaryResolver.resolve("applycal")
@@ -383,6 +431,8 @@ public struct ArgyllRunner: Sendable {
// Remove any stale tmp from a previous crash.
try? fm.removeItem(at: tmpURL)
await ensureNotRunning(id: processId)
let outputConfig = ApplycalConfig(
calibrationPath: config.calibrationPath,
inputProfileURL: inputURL,
@@ -398,8 +448,11 @@ public struct ArgyllRunner: Sendable {
workingDirectory: cwd
)
guard result.exitCode == 0 else {
guard result.exitCode == 0, !Task.isCancelled else {
try? fm.removeItem(at: tmpURL)
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
? "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 {
if fm.fileExists(atPath: inputURL.path) {
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
@@ -441,6 +503,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -480,6 +543,7 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
@@ -547,11 +611,21 @@ public struct ArgyllRunner: Sendable {
let binaryURL = binaryResolver.resolve("chartread")
let processId = ProcessID.chartread(cleanBasename)
let processManager = self.processManager
let isXY = config.isXY
return AsyncStream { continuation in
let task = Task {
await ensureNotRunning(id: processId)
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 {
try await processManager.runStreaming(
id: processId,
@@ -582,20 +656,22 @@ public struct ArgyllRunner: Sendable {
switch event {
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
if classified.isRemoveSheetNotice {
continuation.yield(.removeSheetNotice)
}
if classified.sheetNumber != nil || classified.alignmentPatch != nil {
continuation.yield(.prompt(classified))
} else if state != previousOrContinuationState(state, classified) {
// Only emit prompt when the state meaningfully changes.
continuation.yield(.prompt(classified))
} else if state == .tablePlaceSheet || state == .tableAlign {
// Continuation lines in table states are still prompts.
continuation.yield(.prompt(classified))
} else if classified.requestedWarningKey != nil {
let shouldPrompt =
classified.sheetNumber != nil
|| classified.alignmentPatch != nil
|| classified.requestedWarningKey != nil
|| classified.state != previous
|| classified.isTableContinuation
if shouldPrompt {
continuation.yield(.prompt(classified))
}
@@ -632,6 +708,11 @@ public struct ArgyllRunner: Sendable {
}
}
if Task.isCancelled {
continuation.finish()
return
}
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
if let code = exitCode, code == 0 {
if FileManager.default.fileExists(atPath: canonical.path) {
@@ -647,13 +728,11 @@ public struct ArgyllRunner: Sendable {
continuation.onTermination = { _ in
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.
@@ -665,17 +744,14 @@ public struct ArgyllRunner: Sendable {
/// 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) {
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
guard let cleanBasename else { return }
let processId = ProcessID.chartread(cleanBasename)
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)
}
}
@@ -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()
}
/// `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(
bundle: Bundle = .main
) -> (version: String, build: String) {
) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
let build = 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)
}
}
@@ -153,7 +153,7 @@ public enum ChartreadClassifier {
let phrases = [
"'d' if/when done", "d to finish/save", "all strips/patches read",
"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) }) {
return ChartreadClassifyResult(state: .allStripsRead)
@@ -163,20 +163,28 @@ public enum ChartreadClassifier {
// 7. Warnings / prompts needing a key.
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text
let warningSignals = [
"(warning)", "use it anyway", "seem to have read strip pass",
"unexpected response", "seem to have read", "misread",
"try again", "do you want to"
"(warning)", "use it anyway", "seem to have read strip",
"unexpected response", "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?
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.
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"
} 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"
}
@@ -195,12 +203,14 @@ public enum ChartreadClassifier {
!hasLocate
else { return nil }
if lowercased.contains("hit any key to continue")
|| lowercased.contains("hit space to continue")
|| lowercased.contains("calibration")
|| lowercased.contains("calibrate")
if lowercased.contains("calibrat")
|| lowercased.contains("white reference")
|| 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 nil
@@ -209,11 +219,29 @@ public enum ChartreadClassifier {
// 9. Awaiting strip.
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
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 = [
"hit ... read ... strip", "ready to read", "read ... strip ... key",
"hit any key to read", "ready to read strip", "hit a key to read",
"press any key to read", "read strip"
"ready to read",
"hit any key to read",
"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 }
return ChartreadClassifyResult(state: .awaitingStrip)
}
@@ -228,16 +256,27 @@ public enum ChartreadClassifier {
// 11. Error.
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = ["error", "too fast", "too slow", "misread", "failed to read", "failed"]
// 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 }
let lower = text.lowercased()
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 nil
}
}
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
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 {
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.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills.
/// - `kill` drops the stdin handle so writers fail fast.
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
/// - `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 static let rowColorsPrefix = "ROW_COLORS_JSON: "
@@ -92,12 +95,18 @@ public actor ProcessManager {
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
/// Watchdog that forces finalization if EOFs never arrive.
var finalizeTask: Task<Void, Never>?
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
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.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
@@ -105,6 +114,14 @@ public actor ProcessManager {
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)
/// Spawns a streaming child. Returns after spawn; callers wait for
@@ -142,14 +159,6 @@ public actor ProcessManager {
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 stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
@@ -167,6 +176,15 @@ public actor ProcessManager {
guard let self else { return }
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)
@@ -198,17 +216,77 @@ public actor ProcessManager {
"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
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 {
try process.run()
} catch {
_ = box.resume(with: -1)
captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
// Close the parent write ends so readDataToEndOfFile() gets EOF
// as soon as the child exits; the child still has its own copies.
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
return await withTaskCancellationHandler {
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
@@ -217,22 +295,35 @@ public actor ProcessManager {
}.value
let code = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
if let status = box.store(continuation) {
continuation.resume(returning: status)
}
}
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
// 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)
}
}
}
// MARK: - stdin
@@ -255,39 +346,89 @@ public actor ProcessManager {
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
/// Terminates a child. The `exit` event still fires exactly once.
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
public func kill(id: String) {
/// Terminates a child. First runs any registered pre-kill hook, then
/// drops stdin and signals the process. For streaming children the
/// `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] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
} else if child.pendingExitCode == nil {
// The process already exited but `didTerminate` has not
// run; synthesize it so `maybeFinalize` can fire.
didTerminate(id: id, code: child.process.terminationStatus)
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: process.terminationStatus))
}
// Do not emit `.exit` here; `runCaptured` emits the real code
// after the process reaps.
return
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() -> Int {
let ids = Array(children.keys) + Array(captured.keys)
for id in ids { kill(id: id) }
public func killAll() async -> Int {
let ids = runningIDs
for id in ids { await kill(id: id) }
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
private func childEnvironment(extra: [String: String]) -> [String: String] {
@@ -339,6 +480,16 @@ public actor ProcessManager {
child.pendingExitCode = code
try? child.stdin?.close()
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
maybeFinalize(id: id)
}
@@ -351,8 +502,12 @@ public actor ProcessManager {
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
@@ -369,4 +524,20 @@ public actor ProcessManager {
}
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,30 +2,38 @@ import Foundation
/// Computes a consecutive-breach warning from verification history.
///
/// A drift alert triggers when there are at least two `poor` records on
/// distinct calendar days, or two `poor` records at least one hour apart.
/// A drift alert triggers when the most recent chronologically consecutive
/// 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 {
/// Returns an alert message, or `nil` when no consecutive breach exists.
public static func compute(from records: [VerificationRecord]) -> String? {
let poor = records
.filter { $0.status == .poor }
.sorted { $0.timestamp < $1.timestamp }
// Work in chronological order.
let chronological = records.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
// encountered from the end.
var run: [VerificationRecord] = []
for record in chronological.reversed() {
if record.status == .poor {
run.insert(record, at: 0)
} else {
break
}
}
for i in 0..<poor.count {
for j in (i + 1)..<poor.count {
let a = poor[i]
let b = poor[j]
guard run.count >= 2 else { return nil }
let sameDay = Calendar.utc.isDate(a.timestamp, inSameDayAs: b.timestamp)
let oneHour = b.timestamp.timeIntervalSince(a.timestamp) >= 3600
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 \(a.id) and \(b.id)."
}
}
return "Drift alert: poor results between \(first.id) and \(last.id)."
}
return nil
@@ -33,10 +33,32 @@ public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
/// Installs an ICC/ICM profile into the OS colour store.
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
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
public static func install(config: InstallProfileConfig) throws -> InstallProfileResult {
let fm = FileManager.default
public static func install(
config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> InstallProfileResult {
let fm = fileManager
// Source validation.
let sourceURL = config.sourceURL
@@ -55,38 +77,37 @@ public enum ProfileInstaller {
throw ProfileInstallError.sourceTooSmall
}
// Stem security.
let stem = sourceURL.deletingPathExtension().lastPathComponent
guard !stem.contains("..") && !stem.contains("/") && !stem.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
try validateSourceURL(sourceURL)
// Destination directory.
let destDir: URL
if config.options.preferSystem {
destDir = URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
let home = fm.homeDirectoryForCurrentUser
destDir = home.appendingPathComponent("Library/ColorSync/Profiles")
}
// Ensure parent exists.
try? fm.createDirectory(at: destDir, withIntermediateDirectories: true)
let destURL = destDir.appendingPathComponent("\(stem).icc")
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
try? fm.createDirectory(
at: destURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// Collision resolution.
let destExists = fm.fileExists(atPath: destURL.path)
if destExists {
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 {
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(
from: sourceURL,
to: renamedURL,
options: config.options,
fileManager: fm,
overwritten: false,
renamed: true
)
@@ -102,19 +123,53 @@ public enum ProfileInstaller {
from: sourceURL,
to: destURL,
options: config.options,
overwritten: destExists,
fileManager: fm,
overwritten: 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(
from sourceURL: URL,
to destURL: URL,
options: InstallProfileOptions,
fileManager: FileManager,
overwritten: Bool,
renamed: Bool
) throws -> InstallProfileResult {
let fm = FileManager.default
let fm = fileManager
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
// Remove stale tmp.
@@ -123,6 +178,13 @@ public enum ProfileInstaller {
do {
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) {
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
@@ -135,6 +197,10 @@ public enum ProfileInstaller {
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
throw ProfileInstallError.systemRequiresAdminRights
}
if let installError = error as? ProfileInstallError {
throw installError
}
throw ProfileInstallError.copyFailed(error.localizedDescription)
}
@@ -57,10 +57,12 @@ public actor VerificationHistoryStore {
/// Appends a record, trims to capacity, and writes atomically.
///
/// Returns the trimmed list, or `nil` if a write error occurs so the
/// caller can surface the failure without replacing the in-memory list.
/// Loads the existing history first and propagates any load error so an
/// unparseable file is never overwritten.
@discardableResult
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
try load()
var updated = records
updated.append(record)
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") }
/// `select_directory` result (working-directory browse).
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.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
+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)
chartreadTask?.cancel()
isChartreadRunning = false
chartreadState = .idle
currentPrompt = nil
requestedWarningKey = nil
showRemoveSheetNotice = false
}
func sendWarningKey(_ key: String) {
+35 -16
View File
@@ -81,6 +81,15 @@ final class ProfileWorkflowViewModel {
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
restoreCreatedProfileURL()
}
/// Restores `createdProfileURL` from the wizard artefacts or by probing
/// the working directory for an existing `.icc`/`.icm` (#52).
func restoreCreatedProfileURL() {
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
createdProfileURL = wizard.artefacts.profilePath
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
}
// MARK: - Derived
@@ -206,6 +215,7 @@ final class ProfileWorkflowViewModel {
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
@@ -256,7 +266,15 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 5: verify profile
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() {
@@ -333,10 +351,11 @@ final class ProfileWorkflowViewModel {
let timestamp = Date()
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
return VerificationRecord(
id: id,
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
printerName: wizard.printerName ?? "",
printerName: printerName,
avgDE: avg,
maxDE: max,
rmsDE: rms,
@@ -381,7 +400,9 @@ final class ProfileWorkflowViewModel {
openColorPanel: settings.openColorPanelAfterInstall
)
let destURL = installDestination(for: sourceURL, options: options)
do {
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 {
@@ -392,13 +413,19 @@ final class ProfileWorkflowViewModel {
}
runInstall(sourceURL: sourceURL, options: options)
} catch {
wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
showingInstallCollision = false
guard let sourceURL = createdProfileURL,
var options = pendingInstallOptions else { return }
options.collisionPolicy = policy
if policy == .cancel {
installResult = InstallProfileResult(
destPath: "",
@@ -410,20 +437,12 @@ final class ProfileWorkflowViewModel {
)
return
}
runInstall(sourceURL: sourceURL, options: options)
}
private func installDestination(for sourceURL: URL, options: InstallProfileOptions) -> URL {
let stem = sourceURL.deletingPathExtension().lastPathComponent
let fm = FileManager.default
let destDir: URL
if options.preferSystem {
destDir = URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
destDir = fm.homeDirectoryForCurrentUser
.appendingPathComponent("Library/ColorSync/Profiles")
options.collisionPolicy = policy
if policy == .overwrite {
options.forceOverwrite = true
}
return destDir.appendingPathComponent("\(stem).icc")
runInstall(sourceURL: sourceURL, options: options)
}
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
+5 -5
View File
@@ -8,6 +8,7 @@ struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard }
@@ -16,7 +17,8 @@ struct RootView: View {
SidebarView(
workflow: workflow,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true }
onOpenAbout: { showingAbout = true },
showingAllHelp: $showingAllHelp
)
Rectangle()
@@ -48,10 +50,8 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {}
} message: {
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
}
+11 -2
View File
@@ -7,6 +7,7 @@ struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard }
@@ -22,12 +23,20 @@ struct SidebarView: View {
Image(systemName: "gearshape")
}
.buttonStyle(.plain)
.help("Settings")
.helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openSettingsBtn")
Button(action: onOpenAbout) {
Image(systemName: "info.circle")
}
.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)
+1 -2
View File
@@ -87,9 +87,8 @@ struct Stage1View: View {
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { /* CGATS import #94, later */ }
Button("Import Dataset…") { workflow.importMeasurementDataset() }
.accessibilityIdentifier("btn-import-dataset")
.disabled(true)
}
Text(workflow.targetDirectory?.path ?? "No working directory selected")
.font(.caption)
+5 -12
View File
@@ -210,7 +210,9 @@ struct Stage3View: View {
.accessibilityIdentifier("btnCalibrate")
case .awaitingStrip:
Button("Trigger") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate")
.accessibilityIdentifier("btnTrigger")
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneReadEarly")
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
Button(continueTitle) { model.accept() }
.accessibilityIdentifier("btnAccept")
@@ -224,16 +226,6 @@ struct Stage3View: View {
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() }
.accessibilityIdentifier("btnCancel")
}
@@ -374,7 +366,7 @@ struct Stage3View: View {
Button("Finish & Average") {
model.finishAndAverage()
}
.disabled(!model.isFinished || model.isFinishing)
.disabled(!model.canFinish || model.isFinishing)
.accessibilityIdentifier("btnFinishAndAverage")
}
@@ -386,6 +378,7 @@ struct Stage3View: View {
}
.padding(16)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("chartreadAveragingPanel")
}
}
+1
View File
@@ -18,6 +18,7 @@ struct Stage4View: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.restoreCreatedProfileURL() }
}
// MARK: - Header
+5 -2
View File
@@ -21,7 +21,10 @@ struct Stage5View: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.loadHistory() }
.onAppear {
model.restoreCreatedProfileURL()
model.loadHistory()
}
.alert("Install profile", isPresented: $model.showingInstallCollision) {
Button("Overwrite", role: .destructive) {
model.resolveInstallCollision(policy: .overwrite)
@@ -70,7 +73,7 @@ struct Stage5View: View {
.accessibilityIdentifier("driftAlert")
}
if let warning = model.profcheckWarning, !warning.isEmpty, model.driftAlert == nil {
if let warning = model.profcheckWarning, !warning.isEmpty {
Text("\(warning)")
.font(.caption)
.padding(.horizontal, 8)
@@ -243,6 +243,43 @@ final class TargetWorkflowViewModel {
// 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).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds.
@@ -15,16 +15,16 @@ struct ApplycalArgsTests {
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
@Test("Unapply is never sent from build")
func unapplyNotEmitted() throws {
@Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
unapply: true
)
let args = try ApplycalArgs.build(config: config)
// Builder intentionally emits -u because config can set it, but
// the UI layer never passes unapply: true in v2.0.
// Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0.
#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)
}
@Test("Non-poor results do not trigger")
@Test("Non-poor records do not trigger")
func nonPoor() {
let records = [
record(avg: 1.0, at: 0),
@@ -47,6 +47,48 @@ struct DriftAlertTests {
#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 {
VerificationRecord(
id: "vr-\(Int(offset))",
+140 -44
View File
@@ -2,42 +2,165 @@ import Foundation
import Testing
@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")
struct ProfileInstallerTests {
@Test("Copies .icc to user ColorSync folder")
func userInstall() throws {
private func makeTempDir() throws -> URL {
let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}
let source = tmp.appendingPathComponent("test.icc")
let iccData = Data(repeating: 0, count: 256)
try iccData.write(to: source)
private func makeSource(
at dir: URL,
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
// is not practical; instead exercise Core validation on a
// temp-only path via the file URL safety checks and the public
// install against a writable system-like path is tested below.
let result = try ProfileInstaller.install(
config: InstallProfileConfig(sourceURL: source),
fileManager: testFM
)
#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 {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: unsafe))
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error")
} catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") }
} catch {
Issue.record("Unexpected error type: \(error)")
}
}
@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 {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: small))
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error")
} catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") }
@@ -45,31 +168,4 @@ struct ProfileInstallerTests {
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")
func csvQuoting() async throws {
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():
line = read_line()
if not line:
if line == "":
sys.exit(1)
return line.strip()
+6 -5
View File
@@ -93,7 +93,6 @@ final class Milestone4UITests: XCTestCase {
/// End-to-end handheld chartread with the mock fixture produces a
/// canonical .ti3 and unlocks Stage 4.
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()
app.buttons["btnDetectInstruments"].click()
@@ -113,19 +112,21 @@ final class Milestone4UITests: XCTestCase {
app.buttons["btnCalibrate"].click()
// Trigger strip A.
_ = waitFor("btnCalibrate", timeout: 20)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// Trigger strip B.
_ = waitFor("btnCalibrate", timeout: 20)
app.buttons["btnCalibrate"].click()
_ = waitFor("btnTrigger", timeout: 20)
app.buttons["btnTrigger"].click()
// All strips read Done & Save appears.
_ = waitFor("btnDoneRead", timeout: 20)
app.buttons["btnDoneRead"].firstMatch.click()
// Averaging panel appears with one pass snapshot.
_ = waitFor("chartreadAveragingPanel", timeout: 20)
_ = waitFor("passCounterBadge", timeout: 20)
XCTAssertTrue(app.buttons["btnFinishAndAverage"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["btnFinishAndAverage"].isEnabled)
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))
}
}