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