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