Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4261ba2c9 |
@@ -16,6 +16,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
|
||||
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
|
||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||
|
||||
Issue **16 is not an M3 or M6 exit gate.**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum —
|
||||
/// `-d` / `-u` / `-r` still mean different things per binary.
|
||||
public enum ArgsBuilder {
|
||||
/// `["-f", value]` when `value` is non-nil.
|
||||
public static func option(_ flag: String, _ value: String?) -> [String] {
|
||||
guard let value else { return [] }
|
||||
return [flag, value]
|
||||
}
|
||||
|
||||
/// `["-f", trimmed]` when trimmed is non-empty.
|
||||
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
|
||||
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty else { return [] }
|
||||
return [flag, raw]
|
||||
}
|
||||
|
||||
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
|
||||
public static func optionUnlessApprox(
|
||||
_ flag: String,
|
||||
_ value: Double?,
|
||||
skip: Double,
|
||||
epsilon: Double = 0.001,
|
||||
format: String = "%.2f"
|
||||
) -> [String] {
|
||||
guard let value, abs(value - skip) >= epsilon else { return [] }
|
||||
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
|
||||
}
|
||||
|
||||
/// Bare flag when `when` is true.
|
||||
public static func flag(_ flag: String, when: Bool) -> [String] {
|
||||
when ? [flag] : []
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,41 @@ import Foundation
|
||||
|
||||
/// Errors from `ArgyllRunner` executions.
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
case processFailed(code: Int32, logs: [String])
|
||||
case toolFailed(tool: String, code: Int32, logs: [String])
|
||||
case missingArtefact(String)
|
||||
case malformedManifest(String)
|
||||
case instrumentDetectionFailed(String)
|
||||
case chartreadFailed(String)
|
||||
case averageFailed(String)
|
||||
case colprofFailed(String)
|
||||
case printcalFailed(String)
|
||||
case applycalFailed(String)
|
||||
case iccgamutFailed(String)
|
||||
case profcheckFailed(String)
|
||||
case profcheckUnparseable
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let code, _):
|
||||
return "Process exited with code \(code)"
|
||||
case .toolFailed(let tool, let code, let logs):
|
||||
let detail = logs.last.flatMap { $0.isEmpty ? nil : $0 }
|
||||
?? "exited with code \(code)"
|
||||
switch tool {
|
||||
case "chartread":
|
||||
return "Chartread failed: \(detail)"
|
||||
case "average":
|
||||
return "Averaging failed: \(detail)"
|
||||
case "colprof":
|
||||
return "Profile creation failed: \(detail)"
|
||||
case "printcal":
|
||||
return "Calibration curve computation failed: \(detail)"
|
||||
case "applycal":
|
||||
return "Apply calibration failed: \(detail)"
|
||||
case "iccgamut":
|
||||
return "Gamut extraction failed: \(detail)"
|
||||
case "profcheck":
|
||||
return "Profile verification failed: \(detail)"
|
||||
default:
|
||||
return "Process exited with code \(code)"
|
||||
}
|
||||
case .missingArtefact(let path):
|
||||
return "Expected output file was not created: \(path)"
|
||||
case .malformedManifest(let reason):
|
||||
return "Failed to parse printtarg manifest: \(reason)"
|
||||
case .instrumentDetectionFailed(let reason):
|
||||
return "Instrument detection failed: \(reason)"
|
||||
case .chartreadFailed(let reason):
|
||||
return "Chartread failed: \(reason)"
|
||||
case .averageFailed(let reason):
|
||||
return "Averaging failed: \(reason)"
|
||||
case .colprofFailed(let reason):
|
||||
return "Profile creation failed: \(reason)"
|
||||
case .printcalFailed(let reason):
|
||||
return "Calibration curve computation failed: \(reason)"
|
||||
case .applycalFailed(let reason):
|
||||
return "Apply calibration failed: \(reason)"
|
||||
case .iccgamutFailed(let reason):
|
||||
return "Gamut extraction failed: \(reason)"
|
||||
case .profcheckFailed(let reason):
|
||||
return "Profile verification failed: \(reason)"
|
||||
case .profcheckUnparseable:
|
||||
return "Profile verification produced unparseable output"
|
||||
}
|
||||
@@ -76,6 +74,48 @@ public struct ArgyllRunner: Sendable {
|
||||
self.binaryResolver = binaryResolver
|
||||
}
|
||||
|
||||
// MARK: - Shared streaming loop (issue #79)
|
||||
|
||||
private func runStreamingTool(
|
||||
name: String,
|
||||
id: String,
|
||||
arguments: [String],
|
||||
workingDirectory: URL?,
|
||||
flushPartialLines: Bool = false,
|
||||
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||
) async throws -> CollectedRun {
|
||||
let binaryURL = binaryResolver.resolve(name)
|
||||
await ensureNotRunning(id: id)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
id: id,
|
||||
binary: binaryURL,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
let run = await collect(
|
||||
id: id,
|
||||
events: events,
|
||||
onLogBatch: onLogBatch,
|
||||
flushPartialLines: flushPartialLines
|
||||
)
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: name,
|
||||
code: run.exitCode ?? -1,
|
||||
logs: run.lines
|
||||
)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
private func requireArtefact(_ url: URL) throws -> URL {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(url.path)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - targen (Stage 1)
|
||||
|
||||
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||
@@ -87,27 +127,16 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("targen")
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "targen",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
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
|
||||
return try requireArtefact(ti1URL)
|
||||
}
|
||||
|
||||
// MARK: - printtarg (Stage 2)
|
||||
@@ -122,26 +151,15 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try PrinttargArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("printtarg")
|
||||
let processId = ProcessID.printtarg(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
let run = try await runStreamingTool(
|
||||
name: "printtarg",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
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 ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
||||
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
||||
}
|
||||
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
|
||||
|
||||
let manifest: PrinttargManifest
|
||||
do {
|
||||
@@ -339,28 +357,16 @@ public struct ArgyllRunner: Sendable {
|
||||
) async throws -> URL {
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try AverageArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("average")
|
||||
let processId = ProcessID.average(config.basename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "average",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
||||
}
|
||||
|
||||
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
||||
}
|
||||
return canonical
|
||||
return try requireArtefact(canonical)
|
||||
}
|
||||
|
||||
// MARK: - colprof (Stage 4)
|
||||
@@ -374,29 +380,15 @@ public struct ArgyllRunner: Sendable {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try ColprofArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("colprof")
|
||||
let processId = ProcessID.colprof(cleanBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "colprof",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
flushPartialLines: true,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(
|
||||
id: processId,
|
||||
events: events,
|
||||
onLogBatch: onLogBatch,
|
||||
flushPartialLines: true
|
||||
)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.colprofFailed(
|
||||
"colprof exited with code \(run.exitCode ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||
@@ -456,16 +448,20 @@ public struct ArgyllRunner: Sendable {
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
throw ArgyllRunnerError.applycalFailed(
|
||||
result.stderr.isEmpty
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: result.exitCode,
|
||||
logs: [result.stderr.isEmpty
|
||||
? "applycal exited with code \(result.exitCode)"
|
||||
: result.stderr
|
||||
: result.stderr]
|
||||
)
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||
throw ArgyllRunnerError.applycalFailed(
|
||||
"applycal did not create temp profile"
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: ["applycal did not create temp profile"]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,8 +469,10 @@ public struct ArgyllRunner: Sendable {
|
||||
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)"
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: ["calibrated profile is too small (\(size) bytes)"]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -486,7 +484,11 @@ public struct ArgyllRunner: Sendable {
|
||||
}
|
||||
} catch {
|
||||
try? fm.removeItem(at: tmpURL)
|
||||
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "applycal",
|
||||
code: -1,
|
||||
logs: [error.localizedDescription]
|
||||
)
|
||||
}
|
||||
|
||||
return inputURL
|
||||
@@ -503,30 +505,16 @@ public struct ArgyllRunner: Sendable {
|
||||
let cwd = profileURL.deletingLastPathComponent()
|
||||
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||
let args = try IccgamutArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("iccgamut")
|
||||
let processId = ProcessID.iccgamut(stem: stem)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
_ = try await runStreamingTool(
|
||||
name: "iccgamut",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.iccgamutFailed(
|
||||
"iccgamut exited with code \(run.exitCode ?? -1)"
|
||||
)
|
||||
}
|
||||
|
||||
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||
guard FileManager.default.fileExists(atPath: gamURL.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(gamURL.path)
|
||||
}
|
||||
return gamURL
|
||||
return try requireArtefact(gamURL)
|
||||
}
|
||||
|
||||
// MARK: - profcheck (Stage 5 verification)
|
||||
@@ -539,30 +527,18 @@ public struct ArgyllRunner: Sendable {
|
||||
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||
let ti3Path = config.ti3URL.path
|
||||
|
||||
let iccURL = Self.resolveProfileForVerification(config.iccURL)
|
||||
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
|
||||
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||
|
||||
let args = try ProfcheckArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("profcheck")
|
||||
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
let run = try await runStreamingTool(
|
||||
name: "profcheck",
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.profcheckFailed(
|
||||
run.stderr.isEmpty
|
||||
? "profcheck exited with code \(run.exitCode ?? -1)"
|
||||
: run.stderr
|
||||
)
|
||||
}
|
||||
|
||||
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let report = ProfcheckParser.parse(output)
|
||||
@@ -572,15 +548,6 @@ public struct ArgyllRunner: Sendable {
|
||||
return report
|
||||
}
|
||||
|
||||
private static func resolveProfileForVerification(_ url: URL) -> URL {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: url.path) { return url }
|
||||
let alt = url.pathExtension.lowercased() == "icc"
|
||||
? url.deletingPathExtension().appendingPathExtension("icm")
|
||||
: url.deletingPathExtension().appendingPathExtension("icc")
|
||||
return fm.fileExists(atPath: alt.path) ? alt : url
|
||||
}
|
||||
|
||||
// MARK: - chartread (Stage 3 interactive)
|
||||
|
||||
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||
@@ -596,7 +563,7 @@ public struct ArgyllRunner: Sendable {
|
||||
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -606,7 +573,7 @@ public struct ArgyllRunner: Sendable {
|
||||
args = try ChartreadArgs.build(config: config)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -637,7 +604,7 @@ public struct ArgyllRunner: Sendable {
|
||||
workingDirectory: cwd
|
||||
)
|
||||
} catch {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
@@ -724,7 +691,11 @@ public struct ArgyllRunner: Sendable {
|
||||
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||
}
|
||||
} else {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
||||
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
|
||||
tool: "chartread",
|
||||
code: exitCode ?? -1,
|
||||
logs: ["chartread exited with code \(exitCode ?? -1)"]
|
||||
)))
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
@@ -768,30 +739,19 @@ public struct ArgyllRunner: Sendable {
|
||||
) 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 cleanBasename = try PathSecurity.sanitizeBasename(
|
||||
CalibrationIdentity.prefix(config.basename)
|
||||
)
|
||||
let processId = ProcessID.targen(cleanBasename)
|
||||
_ = try await runStreamingTool(
|
||||
name: "targen",
|
||||
id: processId,
|
||||
arguments: args,
|
||||
workingDirectory: cwd,
|
||||
onLogBatch: onLogBatch
|
||||
)
|
||||
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
|
||||
return try requireArtefact(ti1URL)
|
||||
}
|
||||
|
||||
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
|
||||
@@ -805,7 +765,7 @@ public struct ArgyllRunner: Sendable {
|
||||
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 calBasename = CalibrationIdentity.prefix(config.ti3Basename)
|
||||
let processId = ProcessID.printcal(calBasename)
|
||||
|
||||
await ensureNotRunning(id: processId)
|
||||
@@ -821,10 +781,12 @@ public struct ArgyllRunner: Sendable {
|
||||
}
|
||||
|
||||
guard result.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.printcalFailed(
|
||||
result.stderr.isEmpty
|
||||
throw ArgyllRunnerError.toolFailed(
|
||||
tool: "printcal",
|
||||
code: result.exitCode,
|
||||
logs: [result.stderr.isEmpty
|
||||
? "printcal exited with code \(result.exitCode)"
|
||||
: result.stderr
|
||||
: result.stderr]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ public enum PrinttargArgs {
|
||||
}
|
||||
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||
|
||||
if !cleanBasename.hasPrefix("CAL_"),
|
||||
if !CalibrationIdentity.isCalibration(cleanBasename),
|
||||
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!cal.isEmpty {
|
||||
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||
|
||||
@@ -78,6 +78,18 @@ public enum ArtefactProbe {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Resolve an explicit profile URL, flipping `.icc` ↔ `.icm` when the
|
||||
/// requested path is missing (#69 / issue #83).
|
||||
public static func resolveProfile(
|
||||
_ url: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> URL {
|
||||
if fileManager.fileExists(atPath: url.path) { return url }
|
||||
let altExt = url.pathExtension.lowercased() == "icc" ? "icm" : "icc"
|
||||
let alt = url.deletingPathExtension().appendingPathExtension(altExt)
|
||||
return fileManager.fileExists(atPath: alt.path) ? alt : url
|
||||
}
|
||||
|
||||
/// Default extension for a *new* profile on macOS (#69).
|
||||
public static let defaultProfileExtension = "icc"
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
/// Policy when a JSON file exists but cannot be decoded.
|
||||
public enum JSONCorruptPolicy: Sendable {
|
||||
/// Return `defaultValue` and leave the file untouched.
|
||||
case replaceWithDefault
|
||||
/// Throw the decode error. Callers must not overwrite the file.
|
||||
case throwCorrupt
|
||||
}
|
||||
|
||||
/// Shared pretty-printed JSON file façade used by settings, wizard state,
|
||||
/// and verification history.
|
||||
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
||||
public let fileURL: URL
|
||||
public let corrupt: JSONCorruptPolicy
|
||||
private let defaultValue: @Sendable () -> T
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(
|
||||
fileURL: URL,
|
||||
corrupt: JSONCorruptPolicy,
|
||||
defaultValue: @escaping @Sendable () -> T,
|
||||
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
|
||||
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
self.corrupt = corrupt
|
||||
self.defaultValue = defaultValue
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = dateEncoding
|
||||
self.encoder = encoder
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = dateDecoding
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
/// Encodes `value` with the shared pretty / sorted-keys encoder.
|
||||
public func encodePretty(_ value: T) throws -> Data {
|
||||
try encoder.encode(value)
|
||||
}
|
||||
|
||||
public func load() throws -> T {
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: fileURL.path) else {
|
||||
return defaultValue()
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
switch corrupt {
|
||||
case .replaceWithDefault:
|
||||
return defaultValue()
|
||||
case .throwCorrupt:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
switch corrupt {
|
||||
case .replaceWithDefault:
|
||||
return defaultValue()
|
||||
case .throwCorrupt:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func save(_ value: T) throws {
|
||||
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
/// Pretty-printed, sorted-keys encoder used by preset export.
|
||||
public static func icceryPretty() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
@@ -51,58 +51,6 @@ public struct PatchColor: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIEXYZ: Codable, Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.x = try container.decode(Double.self)
|
||||
self.y = try container.decode(Double.self)
|
||||
self.z = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(x: Double, y: Double, z: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(x)
|
||||
try container.encode(y)
|
||||
try container.encode(z)
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIELab: Codable, Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.l = try container.decode(Double.self)
|
||||
self.a = try container.decode(Double.self)
|
||||
self.b = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(l: Double, a: Double, b: Double) {
|
||||
self.l = l
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(l)
|
||||
try container.encode(a)
|
||||
try container.encode(b)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SpectralData: Codable, Sendable, Equatable {
|
||||
public let bands: Int
|
||||
public let startNM: Double
|
||||
|
||||
@@ -154,11 +154,9 @@ public enum ColorDifference {
|
||||
|
||||
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||
if let lab = color.lab {
|
||||
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
||||
}
|
||||
if let lab = color.lab { return lab }
|
||||
guard let xyz = color.xyz else { return nil }
|
||||
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
||||
return LabColorMath.xyzToLab(xyz)
|
||||
}
|
||||
|
||||
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||
public struct XYZColor: Sendable, Equatable {
|
||||
/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
|
||||
public struct XYZColor: Codable, Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
@@ -11,10 +12,24 @@ public struct XYZColor: Sendable, Equatable {
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.x = try container.decode(Double.self)
|
||||
self.y = try container.decode(Double.self)
|
||||
self.z = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(x)
|
||||
try container.encode(y)
|
||||
try container.encode(z)
|
||||
}
|
||||
}
|
||||
|
||||
/// CIELab value (D50).
|
||||
public struct LabColor: Sendable, Equatable {
|
||||
/// CIELab value (D50). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
|
||||
public struct LabColor: Codable, Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
@@ -24,8 +39,26 @@ public struct LabColor: Sendable, Equatable {
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.l = try container.decode(Double.self)
|
||||
self.a = try container.decode(Double.self)
|
||||
self.b = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(l)
|
||||
try container.encode(a)
|
||||
try container.encode(b)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON aliases used by `chartread` row payloads.
|
||||
public typealias CIEXYZ = XYZColor
|
||||
public typealias CIELab = LabColor
|
||||
|
||||
/// sRGB colour in 0–1 display space.
|
||||
public struct DisplayRGB: Sendable, Equatable {
|
||||
public let r: Double
|
||||
|
||||
@@ -136,17 +136,14 @@ public actor ProcessManager {
|
||||
) throws {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
let prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: true
|
||||
)
|
||||
let process = prepared.process
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
@@ -154,13 +151,13 @@ public actor ProcessManager {
|
||||
|
||||
children[id] = RunningChild(
|
||||
process: process,
|
||||
stdin: stdinPipe.fileHandleForWriting,
|
||||
stdin: prepared.stdinPipe?.fileHandleForWriting,
|
||||
stdoutDecoder: ProcessLineDecoder(),
|
||||
stderrDecoder: ProcessLineDecoder()
|
||||
)
|
||||
|
||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = stderrPipe.fileHandleForReading
|
||||
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = prepared.stderrPipe.fileHandleForReading
|
||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard let self else { return }
|
||||
@@ -172,21 +169,13 @@ public actor ProcessManager {
|
||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] proc in
|
||||
attachExitWatchdog(process) { [weak self] code in
|
||||
guard let self else { return }
|
||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||
Task { await self.didTerminate(id: id, code: code) }
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Fallback watchdog: very fast child exits can race past the
|
||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
||||
// blocks the detached thread and guarantees didTerminate runs.
|
||||
Task.detached { [weak self, process] in
|
||||
process.waitUntilExit()
|
||||
guard let self else { return }
|
||||
await self.didTerminate(id: id, code: process.terminationStatus)
|
||||
}
|
||||
} catch {
|
||||
preKillHooks.removeValue(forKey: id)
|
||||
children.removeValue(forKey: id)
|
||||
@@ -210,15 +199,16 @@ public actor ProcessManager {
|
||||
) async throws -> CapturedResult {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
let prepared = makeProcess(
|
||||
binary: binary,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeStdin: false
|
||||
)
|
||||
let process = prepared.process
|
||||
let stdoutPipe = prepared.stdoutPipe
|
||||
let stderrPipe = prepared.stderrPipe
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
@@ -275,20 +265,12 @@ public actor ProcessManager {
|
||||
}
|
||||
}
|
||||
let box = Box()
|
||||
capturedProcess.terminationHandler = { proc in
|
||||
_ = box.resume(with: proc.terminationStatus)
|
||||
attachExitWatchdog(capturedProcess) { status in
|
||||
_ = box.resume(with: status)
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
// Fallback watchdog: very fast child exits can race past the
|
||||
// terminationHandler delivery on a loaded host. waitUntilExit()
|
||||
// blocks the detached thread and resumes the box if the handler
|
||||
// did not already do so (#50, #52).
|
||||
Task.detached { [capturedProcess] in
|
||||
capturedProcess.waitUntilExit()
|
||||
_ = box.resume(with: capturedProcess.terminationStatus)
|
||||
}
|
||||
} catch {
|
||||
_ = box.resume(with: -1)
|
||||
captured.removeValue(forKey: id)
|
||||
@@ -371,12 +353,7 @@ public actor ProcessManager {
|
||||
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))
|
||||
}
|
||||
emitStdoutLine(id: id, line: tail)
|
||||
}
|
||||
if let tail = child.stderrDecoder.flushPartial() {
|
||||
emit(.stderr(id: id, line: tail))
|
||||
@@ -447,6 +424,65 @@ public actor ProcessManager {
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private struct PreparedProcess {
|
||||
let process: Process
|
||||
let stdinPipe: Pipe?
|
||||
let stdoutPipe: Pipe
|
||||
let stderrPipe: Pipe
|
||||
}
|
||||
|
||||
private func makeProcess(
|
||||
binary: URL,
|
||||
arguments: [String],
|
||||
workingDirectory: URL?,
|
||||
environment: [String: String],
|
||||
includeStdin: Bool
|
||||
) -> PreparedProcess {
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
let stdinPipe: Pipe? = includeStdin ? Pipe() : nil
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
if let stdinPipe {
|
||||
process.standardInput = stdinPipe
|
||||
}
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
return PreparedProcess(
|
||||
process: process,
|
||||
stdinPipe: stdinPipe,
|
||||
stdoutPipe: stdoutPipe,
|
||||
stderrPipe: stderrPipe
|
||||
)
|
||||
}
|
||||
|
||||
/// terminationHandler can lose a fast-exit race on a loaded host;
|
||||
/// `waitUntilExit` on a detached thread is the fallback (#50, #52).
|
||||
private func attachExitWatchdog(
|
||||
_ process: Process,
|
||||
onExit: @escaping @Sendable (Int32) -> Void
|
||||
) {
|
||||
process.terminationHandler = { proc in
|
||||
onExit(proc.terminationStatus)
|
||||
}
|
||||
Task.detached { [process] in
|
||||
process.waitUntilExit()
|
||||
onExit(process.terminationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func emitStdoutLine(id: String, line: String) {
|
||||
if line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
|
||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||
@@ -478,15 +514,14 @@ public actor ProcessManager {
|
||||
|
||||
let log = AppLogger(category: "subprocess")
|
||||
for line in lines {
|
||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else if isStderr {
|
||||
if !isStderr {
|
||||
emitStdoutLine(id: id, line: line)
|
||||
if !line.hasPrefix(Self.rowColorsPrefix) {
|
||||
log.info("[\(id)] \(line)")
|
||||
}
|
||||
} else {
|
||||
log.warn("[\(id)] \(line)")
|
||||
emit(.stderr(id: id, line: line))
|
||||
} else {
|
||||
log.info("[\(id)] \(line)")
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,11 +563,7 @@ public actor ProcessManager {
|
||||
// Flush unterminated tail lines.
|
||||
if var decoder = Optional(child.stdoutDecoder),
|
||||
let tail = decoder.finish() {
|
||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: tail))
|
||||
}
|
||||
emitStdoutLine(id: id, line: tail)
|
||||
}
|
||||
if var decoder = Optional(child.stderrDecoder),
|
||||
let tail = decoder.finish() {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
|
||||
///
|
||||
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
|
||||
/// and the runner all derive identity from this type. Do not add a second
|
||||
/// `hasPrefix("CAL_")` ternary elsewhere.
|
||||
public struct CalibrationIdentity: Equatable, Sendable {
|
||||
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
|
||||
public var originalBasename: String
|
||||
/// Always `CAL_{original}` when original is non-empty.
|
||||
public var calibrationBasename: String
|
||||
|
||||
public init(originalBasename: String, calibrationBasename: String) {
|
||||
self.originalBasename = originalBasename
|
||||
self.calibrationBasename = calibrationBasename
|
||||
}
|
||||
|
||||
public static func isCalibration(_ basename: String) -> Bool {
|
||||
basename.hasPrefix("CAL_")
|
||||
}
|
||||
|
||||
/// The only place that adds a `CAL_` prefix.
|
||||
public static func prefix(_ original: String) -> String {
|
||||
if original.isEmpty { return original }
|
||||
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
|
||||
}
|
||||
|
||||
/// Strip a single leading `CAL_` if present.
|
||||
public static func strip(_ basename: String) -> String {
|
||||
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
|
||||
}
|
||||
|
||||
/// Derive identity from the live wizard basename and the persisted
|
||||
/// original. A non-empty persisted original wins over a `CAL_` live
|
||||
/// name (Force Quit mid-calibration).
|
||||
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
|
||||
if liveBasename.isEmpty && persistedOriginal.isEmpty {
|
||||
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
|
||||
}
|
||||
let original: String
|
||||
if liveBasename.hasPrefix("CAL_") {
|
||||
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
|
||||
} else if liveBasename.isEmpty {
|
||||
original = persistedOriginal
|
||||
} else {
|
||||
original = liveBasename
|
||||
}
|
||||
return CalibrationIdentity(
|
||||
originalBasename: original,
|
||||
calibrationBasename: prefix(original)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
|
||||
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||
}
|
||||
|
||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||
args.append(calBasename)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -26,18 +26,13 @@ public enum ColprofArgs {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
|
||||
var args: [String] = ["-v"]
|
||||
|
||||
args.append(contentsOf: ["-a", config.algorithm])
|
||||
args.append(contentsOf: ["-q", config.quality])
|
||||
|
||||
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
|
||||
args.append(contentsOf: ["-t", intent])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
|
||||
|
||||
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||
switch fwa.lowercased() {
|
||||
case "none", "":
|
||||
// "none" omits the flag; an explicit empty string means bare -f.
|
||||
if fwa.isEmpty {
|
||||
args.append("-f")
|
||||
}
|
||||
@@ -46,32 +41,20 @@ public enum ColprofArgs {
|
||||
}
|
||||
}
|
||||
|
||||
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
|
||||
args.append(contentsOf: ["-i", illuminant])
|
||||
}
|
||||
|
||||
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
|
||||
args.append(contentsOf: ["-o", observer])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
|
||||
|
||||
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||
args.append(contentsOf: ["-c", inputCond])
|
||||
}
|
||||
|
||||
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||
args.append(contentsOf: ["-d", outputCond])
|
||||
}
|
||||
|
||||
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
|
||||
if let description = profileDescription, !description.isEmpty {
|
||||
args.append(contentsOf: ["-D", description])
|
||||
}
|
||||
|
||||
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
|
||||
args.append(contentsOf: ["-C", copyright])
|
||||
}
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
|
||||
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
|
||||
|
||||
args.append(cleanBasename)
|
||||
return args
|
||||
|
||||
@@ -107,7 +107,7 @@ public enum PrintcalArgs {
|
||||
}
|
||||
args.append(contentsOf: ["-o", config.outputURL.path])
|
||||
|
||||
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
|
||||
let calBasename = CalibrationIdentity.prefix(cleanBasename)
|
||||
args.append(calBasename)
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ public actor VerificationHistoryStore {
|
||||
private var records: [VerificationRecord] = []
|
||||
|
||||
private let capacity: Int
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let fileStore: JSONFileStore<[VerificationRecord]>
|
||||
|
||||
public init(
|
||||
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||
@@ -22,13 +21,13 @@ public actor VerificationHistoryStore {
|
||||
) {
|
||||
self.url = url
|
||||
self.capacity = capacity
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
self.encoder.dateEncodingStrategy = .iso8601
|
||||
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
self.fileStore = JSONFileStore(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] },
|
||||
dateEncoding: .iso8601,
|
||||
dateDecoding: .iso8601
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads records from disk. Returns the existing cache if already loaded.
|
||||
@@ -37,10 +36,8 @@ public actor VerificationHistoryStore {
|
||||
/// is never overwritten in that case.
|
||||
public func load() throws -> [VerificationRecord] {
|
||||
guard records.isEmpty else { return records }
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: url.path),
|
||||
let data = try? Data(contentsOf: url) else { return [] }
|
||||
records = try decoder.decode([VerificationRecord].self, from: data)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
|
||||
records = try fileStore.load()
|
||||
return records
|
||||
}
|
||||
|
||||
@@ -106,8 +103,7 @@ public actor VerificationHistoryStore {
|
||||
|
||||
/// Writes `records` through a temp file and rename.
|
||||
private func write(_ records: [VerificationRecord]) throws {
|
||||
let data = try encoder.encode(records)
|
||||
try AtomicFileWriter.write(data, to: url)
|
||||
try fileStore.save(records)
|
||||
}
|
||||
|
||||
private func csvRow(_ fields: [String]) -> String {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
|
||||
extension TargenConfig {
|
||||
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
|
||||
/// `nil` when the preset omitted them so argv builders skip the flag.
|
||||
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
|
||||
self.init(
|
||||
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
|
||||
patchCount: preset.patchCount,
|
||||
whitePatches: preset.whitePatches,
|
||||
blackPatches: preset.blackPatches,
|
||||
greySteps: preset.greySteps,
|
||||
singleChannelSteps: preset.singleChannelSteps,
|
||||
neutralSteps: preset.neutralSteps,
|
||||
neutralConcentration: preset.neutralConcentration,
|
||||
preconditioningProfile: preset.preconditioningProfile,
|
||||
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil,
|
||||
ofpsAdaptation: preset.ofpsAdaptation,
|
||||
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
|
||||
totalInkLimit: preset.totalInkLimit,
|
||||
darkEmphasis: preset.darkEmphasis,
|
||||
devicePower: preset.devicePower,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PrinttargConfig {
|
||||
public init(
|
||||
preset: ProfilingPreset,
|
||||
basename: String,
|
||||
workingDirectory: URL?,
|
||||
calibrationFile: String?,
|
||||
label: String? = nil
|
||||
) {
|
||||
let page: PageSize
|
||||
let customW: Double
|
||||
let customH: Double
|
||||
if let size = PageSize(rawValue: preset.pageSize) {
|
||||
page = size
|
||||
customW = 210
|
||||
customH = 297
|
||||
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
|
||||
page = .custom
|
||||
customW = w
|
||||
customH = h
|
||||
} else {
|
||||
page = .a4
|
||||
customW = 210
|
||||
customH = 297
|
||||
}
|
||||
|
||||
let layout: LayoutOrder
|
||||
let seed = preset.randomSeed ?? 1
|
||||
if preset.noRandomize == true {
|
||||
layout = .raster
|
||||
} else if seed == 1 {
|
||||
layout = .deterministic
|
||||
} else {
|
||||
layout = .customSeed
|
||||
}
|
||||
|
||||
self.init(
|
||||
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
|
||||
pageSize: page,
|
||||
customPageWidth: customW,
|
||||
customPageHeight: customH,
|
||||
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
|
||||
dpi: preset.dpi,
|
||||
layoutOrder: layout,
|
||||
customSeed: seed,
|
||||
label: label,
|
||||
calibrationFile: calibrationFile,
|
||||
calibrationEmbedOnly: false,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ColprofConfig {
|
||||
public init(
|
||||
preset: ProfilingPreset,
|
||||
basename: String,
|
||||
workingDirectory: URL?,
|
||||
description: String? = nil,
|
||||
copyright: String? = nil
|
||||
) {
|
||||
self.init(
|
||||
algorithm: preset.colprofAlgorithm ?? "l",
|
||||
quality: preset.colprofQuality ?? "m",
|
||||
intent: Self.nilIfEmpty(preset.colprofIntent),
|
||||
fwa: preset.colprofFwa,
|
||||
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
|
||||
observer: Self.nilIfEmpty(preset.colprofObserver),
|
||||
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
|
||||
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
|
||||
description: description,
|
||||
copyright: copyright,
|
||||
basename: basename,
|
||||
workingDirectory: workingDirectory
|
||||
)
|
||||
}
|
||||
|
||||
private static func nilIfEmpty(_ value: String?) -> String? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
extension PageSize {
|
||||
/// `"210x297"` custom page parse used by presets (issue #82).
|
||||
public static func parseCustom(_ raw: String) -> (Double, Double)? {
|
||||
let parts = raw.lowercased().split(separator: "x")
|
||||
guard parts.count == 2,
|
||||
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||
w >= 50, h >= 50 else { return nil }
|
||||
return (w, h)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfilingPreset {
|
||||
/// Snapshot of the three live configs plus calibration toggles.
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
targen: TargenConfig,
|
||||
printtarg: PrinttargConfig,
|
||||
colprof: ColprofConfig,
|
||||
calibrationFile: String?,
|
||||
applyCalibration: Bool?
|
||||
) {
|
||||
let pageSize: String
|
||||
if printtarg.pageSize == .custom {
|
||||
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
|
||||
} else {
|
||||
pageSize = printtarg.pageSize.rawValue
|
||||
}
|
||||
self.init(
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||
patchCount: targen.patchCount,
|
||||
whitePatches: targen.whitePatches,
|
||||
blackPatches: targen.blackPatches,
|
||||
greySteps: targen.greySteps,
|
||||
singleChannelSteps: targen.singleChannelSteps,
|
||||
neutralSteps: targen.neutralSteps,
|
||||
neutralConcentration: targen.neutralConcentration,
|
||||
preconditioningProfile: targen.preconditioningProfile,
|
||||
ofpsHighQuality: targen.ofpsHighQuality,
|
||||
ofpsAdaptation: targen.ofpsAdaptation,
|
||||
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
|
||||
totalInkLimit: targen.totalInkLimit,
|
||||
darkEmphasis: targen.darkEmphasis,
|
||||
devicePower: targen.devicePower,
|
||||
instrument: printtarg.instrument.rawValue,
|
||||
pageSize: pageSize,
|
||||
bitDepth: printtarg.bitDepth.rawValue,
|
||||
dpi: printtarg.dpi,
|
||||
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
|
||||
noRandomize: printtarg.layoutOrder == .raster,
|
||||
calibrationFile: calibrationFile,
|
||||
applyCalibration: applyCalibration,
|
||||
colprofAlgorithm: colprof.algorithm,
|
||||
colprofQuality: colprof.quality,
|
||||
colprofIntent: colprof.intent,
|
||||
colprofFwa: colprof.fwa,
|
||||
colprofIlluminant: colprof.illuminant,
|
||||
colprofObserver: colprof.observer,
|
||||
colprofInputViewingCond: colprof.inputViewingCond,
|
||||
colprofOutputViewingCond: colprof.outputViewingCond
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,7 @@ public final class PresetStore: Sendable {
|
||||
|
||||
/// Single-preset pretty JSON export.
|
||||
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(preset)
|
||||
return try JSONEncoder.icceryPretty().encode(preset)
|
||||
}
|
||||
|
||||
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||
|
||||
@@ -4,8 +4,8 @@ import Foundation
|
||||
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||
/// (issue #5 — the v1 path is never read).
|
||||
///
|
||||
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// Writes are atomic (`JSONFileStore` → `AtomicFileWriter`). Invalid/corrupt
|
||||
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||
/// reclassify swatches.
|
||||
public final class SettingsStore: Sendable {
|
||||
|
||||
@@ -14,18 +14,19 @@ public final class SettingsStore: Sendable {
|
||||
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<AppSettings>
|
||||
|
||||
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> AppSettings {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||
else {
|
||||
return .default
|
||||
}
|
||||
return settings
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
/// Validates before persisting — throws `SettingsError` listing
|
||||
@@ -35,9 +36,7 @@ public final class SettingsStore: Sendable {
|
||||
guard errors.isEmpty else {
|
||||
throw SettingsError.validationFailed(errors)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||
try store.save(settings)
|
||||
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,23 +74,24 @@ public struct WizardState: Codable, Equatable, Sendable {
|
||||
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||
public final class WizardStateStore: Sendable {
|
||||
public let fileURL: URL
|
||||
private let store: JSONFileStore<WizardState>
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
self.store = JSONFileStore(
|
||||
fileURL: fileURL,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
}
|
||||
|
||||
public func load() -> WizardState {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||
else { return .default }
|
||||
return state
|
||||
(try? store.load()) ?? .default
|
||||
}
|
||||
|
||||
public func save(_ state: WizardState) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||
try store.save(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,15 @@ final class CalibrationViewModel {
|
||||
return cwd.appendingPathComponent("\(calBasename).ti3")
|
||||
}
|
||||
|
||||
private var calBasename: String {
|
||||
if wizard.basename.hasPrefix("CAL_") { return wizard.basename }
|
||||
let original = !wizard.calibrationOriginalBasename.isEmpty
|
||||
? wizard.calibrationOriginalBasename
|
||||
: wizard.basename
|
||||
return "CAL_\(original)"
|
||||
private var identity: CalibrationIdentity {
|
||||
CalibrationIdentity.parse(
|
||||
liveBasename: wizard.basename,
|
||||
persistedOriginal: wizard.calibrationOriginalBasename
|
||||
)
|
||||
}
|
||||
|
||||
private var calBasename: String { identity.calibrationBasename }
|
||||
|
||||
private var calOutputURL: URL? {
|
||||
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
|
||||
return cwd.appendingPathComponent("\(calBasename).cal")
|
||||
@@ -68,13 +69,12 @@ final class CalibrationViewModel {
|
||||
func generateTarget() {
|
||||
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
|
||||
// Snapshot the original (pre-CAL_) basename before changing the live one.
|
||||
if !wizard.basename.hasPrefix("CAL_") {
|
||||
wizard.calibrationOriginalBasename = wizard.basename
|
||||
} else if wizard.calibrationOriginalBasename.isEmpty {
|
||||
wizard.calibrationOriginalBasename = String(wizard.basename.dropFirst(4))
|
||||
}
|
||||
let original = wizard.calibrationOriginalBasename
|
||||
wizard.basename = "CAL_\(original)"
|
||||
let identity = CalibrationIdentity.parse(
|
||||
liveBasename: wizard.basename,
|
||||
persistedOriginal: wizard.calibrationOriginalBasename
|
||||
)
|
||||
wizard.calibrationOriginalBasename = identity.originalBasename
|
||||
wizard.basename = identity.calibrationBasename
|
||||
wizard.sessionMode = .calibration
|
||||
|
||||
isGenerating = true
|
||||
@@ -87,7 +87,7 @@ final class CalibrationViewModel {
|
||||
whitePatches: whitePatches,
|
||||
includeNeutralEmphasis: includeNeutralEmphasis,
|
||||
inkLimit: inkLimitValue,
|
||||
basename: original,
|
||||
basename: identity.originalBasename,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
|
||||
@@ -95,11 +95,9 @@ final class CalibrationViewModel {
|
||||
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)
|
||||
}
|
||||
}
|
||||
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
self?.calibrationLog.append(contentsOf: batch)
|
||||
})
|
||||
self.wizard.refreshGating()
|
||||
self.wizard.showNotice("Calibration target generated.")
|
||||
self.wizard.go(to: .layOutPrint)
|
||||
@@ -163,11 +161,9 @@ final class CalibrationViewModel {
|
||||
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)
|
||||
}
|
||||
}
|
||||
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
self?.calibrationLog.append(contentsOf: batch)
|
||||
})
|
||||
self.computedCalURL = url
|
||||
self.profile.calibrationFile = url.path
|
||||
self.profile.applyCalibration = self.applyToProfile
|
||||
|
||||
@@ -29,13 +29,8 @@ final class FileDialogService {
|
||||
|
||||
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "target.ti1"
|
||||
panel.allowedContentTypes = utTypes(["ti1"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Choose the .ti1 target file to create"
|
||||
return run(panel)
|
||||
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
|
||||
message: "Choose the .ti1 target file to create")
|
||||
}
|
||||
|
||||
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||
@@ -66,12 +61,7 @@ final class FileDialogService {
|
||||
|
||||
/// `selectCsvSavePath` — verification-history CSV export.
|
||||
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "verification-history.csv"
|
||||
panel.allowedContentTypes = utTypes(["csv"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
return run(panel)
|
||||
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
|
||||
}
|
||||
|
||||
/// `selectCalFile` — `.cal` calibration curves.
|
||||
@@ -88,17 +78,27 @@ final class FileDialogService {
|
||||
|
||||
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "\(name).json"
|
||||
panel.allowedContentTypes = utTypes(["json"])
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
panel.message = "Export this preset as JSON"
|
||||
return run(panel)
|
||||
save(named: "\(name).json", extensions: ["json"], startingAt: start,
|
||||
message: "Export this preset as JSON")
|
||||
}
|
||||
|
||||
// MARK: - Internals (private — not a shared public picker API)
|
||||
|
||||
private func save(
|
||||
named: String,
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
message: String? = nil
|
||||
) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = named
|
||||
panel.allowedContentTypes = utTypes(extensions)
|
||||
panel.allowsOtherFileTypes = false
|
||||
panel.directoryURL = start
|
||||
if let message { panel.message = message }
|
||||
return run(panel)
|
||||
}
|
||||
|
||||
private func open(
|
||||
extensions: [String],
|
||||
startingAt start: URL?,
|
||||
|
||||
@@ -70,8 +70,7 @@ final class MeasurementWorkflowViewModel {
|
||||
|
||||
var passSnapshots: [URL] = []
|
||||
var isFinishing = false
|
||||
var finishNotice: String?
|
||||
var finishNoticeIsError = false
|
||||
var finishNotice: Notice?
|
||||
var resumedFromTi2 = false
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
@@ -400,7 +399,6 @@ final class MeasurementWorkflowViewModel {
|
||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||
isFinishing = true
|
||||
finishNotice = nil
|
||||
finishNoticeIsError = false
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -420,10 +418,8 @@ final class MeasurementWorkflowViewModel {
|
||||
)
|
||||
canonical = try await self.environment.runner.runAverage(
|
||||
config: config,
|
||||
onLogBatch: { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.chartreadLog.append(contentsOf: batch)
|
||||
}
|
||||
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
self?.chartreadLog.append(contentsOf: batch)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -432,7 +428,11 @@ final class MeasurementWorkflowViewModel {
|
||||
if self.wizard.isUnlocked(.buildProfile) {
|
||||
self.wizard.go(to: .buildProfile)
|
||||
} else {
|
||||
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
||||
self.finishNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Finished: \(canonical.lastPathComponent) ready.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Fallback to pass 1 promotion if averaging failed.
|
||||
@@ -445,15 +445,24 @@ final class MeasurementWorkflowViewModel {
|
||||
)
|
||||
self.discoverPassSnapshots()
|
||||
self.wizard.refreshGating()
|
||||
self.finishNotice = "Averaging failed — promoted first pass."
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Averaging failed — promoted first pass.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Finish failed: \(error.localizedDescription)",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
self.finishNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Finish failed: \(error.localizedDescription)",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
self.isFinishing = false
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrintSessionViewModel {
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
|
||||
var printers: [Printer] = []
|
||||
var selectedPrinter = ""
|
||||
var printerCaps = PrinterCapabilities()
|
||||
var selectedTray: Int?
|
||||
var selectedMediaType: String?
|
||||
var printOrientation = "portrait"
|
||||
var capturedCupsOptions: [String: String] = [:]
|
||||
var printNotice: Notice?
|
||||
var isPrinting = false
|
||||
private var printTask: Task<Void, Never>?
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
self.wizard = wizard
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let list = try await cups.listPrinters()
|
||||
printers = list
|
||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||
selectedPrinter = list.first { $0.isDefault }?.name
|
||||
?? list.first?.name ?? ""
|
||||
}
|
||||
await reloadSelectedCapabilities()
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Could not list printers: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadSelectedCapabilities() async {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
printerCaps = PrinterCapabilities()
|
||||
return
|
||||
}
|
||||
do {
|
||||
printerCaps = try await environment.cupsService
|
||||
.capabilities(for: selectedPrinter)
|
||||
if selectedMediaType == nil {
|
||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||
}
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Printer properties dialog cancelled.",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Settings captured for \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
printNotice = Notice(kind: .error, text: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.printTask = nil }
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Print failed on \(page.page.filename): "
|
||||
+ error.localizedDescription
|
||||
)
|
||||
isPrinting = false
|
||||
return
|
||||
}
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(printed) page(s) to \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.printTask = nil }
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||
autoHideAfter: nil
|
||||
)
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
kind: .error,
|
||||
text: "Print failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shared monospaced process-log disclosure used by Stage 1 and Stage 2.
|
||||
struct ProcessLogView: View {
|
||||
let lines: [String]
|
||||
var minHeight: CGFloat = 120
|
||||
var maxHeight: CGFloat = 200
|
||||
var containerId: String
|
||||
var logId: String
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(lines.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: minHeight, maxHeight: maxHeight)
|
||||
.accessibilityIdentifier(logId)
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier(containerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Shared hop for coalesced Argyll log batches (issue #80).
|
||||
/// The runner invokes the sink off the main actor; this is the single hop back.
|
||||
enum ProcessRunSupport {
|
||||
static func logSink(
|
||||
_ apply: @escaping @MainActor @Sendable ([String]) -> Void
|
||||
) -> @Sendable ([String]) -> Void {
|
||||
{ batch in
|
||||
Task { @MainActor in apply(batch) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func runLogged<T>(
|
||||
setRunning: (Bool) -> Void,
|
||||
resetLog: () -> Void,
|
||||
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
|
||||
work: (@escaping @Sendable ([String]) -> Void) async throws -> T
|
||||
) async throws -> T {
|
||||
setRunning(true)
|
||||
resetLog()
|
||||
defer { setRunning(false) }
|
||||
return try await work(logSink(onLog))
|
||||
}
|
||||
}
|
||||
@@ -123,11 +123,15 @@ final class ProfileWorkflowViewModel {
|
||||
|
||||
func applyPreset(_ preset: ProfilingPreset?) {
|
||||
guard let preset else { return }
|
||||
algorithm = preset.colprofAlgorithm ?? "l"
|
||||
quality = preset.colprofQuality ?? "m"
|
||||
intent = preset.colprofIntent ?? ""
|
||||
|
||||
if let fwa = preset.colprofFwa {
|
||||
let config = ColprofConfig(
|
||||
preset: preset,
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory
|
||||
)
|
||||
algorithm = config.algorithm
|
||||
quality = config.quality
|
||||
intent = config.intent ?? ""
|
||||
if let fwa = config.fwa {
|
||||
switch fwa.lowercased() {
|
||||
case "none": fwaSelection = .none
|
||||
case "": fwaSelection = .empty
|
||||
@@ -138,11 +142,10 @@ final class ProfileWorkflowViewModel {
|
||||
fwaCustomPath = fwa
|
||||
}
|
||||
}
|
||||
|
||||
illuminant = preset.colprofIlluminant ?? ""
|
||||
observer = preset.colprofObserver ?? ""
|
||||
inputViewingCond = preset.colprofInputViewingCond ?? ""
|
||||
outputViewingCond = preset.colprofOutputViewingCond ?? ""
|
||||
illuminant = config.illuminant ?? ""
|
||||
observer = config.observer ?? ""
|
||||
inputViewingCond = config.inputViewingCond ?? ""
|
||||
outputViewingCond = config.outputViewingCond ?? ""
|
||||
}
|
||||
|
||||
/// Stage 4 form values for saving into a custom preset.
|
||||
@@ -205,16 +208,13 @@ final class ProfileWorkflowViewModel {
|
||||
defer { self.isColprofRunning = false }
|
||||
|
||||
do {
|
||||
let url = try await runner.runColprof(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.colprofLog.append(contentsOf: batch)
|
||||
if let last = batch.last {
|
||||
let progress = ColprofProgressClassifier.classify(line: last)
|
||||
self.updateProgress(progress)
|
||||
}
|
||||
let url = try await runner.runColprof(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
guard let self else { return }
|
||||
self.colprofLog.append(contentsOf: batch)
|
||||
if let last = batch.last {
|
||||
self.updateProgress(ColprofProgressClassifier.classify(line: last))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var finalProfileURL = url
|
||||
|
||||
@@ -231,11 +231,9 @@ final class ProfileWorkflowViewModel {
|
||||
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||
do {
|
||||
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
let gamURL = try await runner.runIccgamut(config: gamConfig, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
})
|
||||
self.createdGamutURL = gamURL
|
||||
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
|
||||
} catch {
|
||||
@@ -327,11 +325,9 @@ final class ProfileWorkflowViewModel {
|
||||
defer { self.isProfcheckRunning = false }
|
||||
|
||||
do {
|
||||
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
|
||||
self?.colprofLog.append(contentsOf: batch)
|
||||
})
|
||||
self.profcheckReport = report
|
||||
if let record = self.makeVerificationRecord(from: report) {
|
||||
let updated = try await self.environment.historyStore.append(record)
|
||||
|
||||
@@ -85,7 +85,7 @@ private struct WizardStageContent: View {
|
||||
case .calibrate:
|
||||
CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
|
||||
@unknown default:
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
Stage1View(workflow: workflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,19 +238,12 @@ struct Stage1View: View {
|
||||
}
|
||||
|
||||
private var logSection: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(workflow.targenLog.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.accessibilityIdentifier("targenLog")
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("targenLogContainer")
|
||||
ProcessLogView(
|
||||
lines: workflow.targenLog,
|
||||
minHeight: 120,
|
||||
maxHeight: 200,
|
||||
containerId: "targenLogContainer",
|
||||
logId: "targenLog"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,20 +171,13 @@ struct Stage2View: View {
|
||||
}
|
||||
|
||||
private var logSection: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(workflow.printtargLog.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 100, maxHeight: 180)
|
||||
.accessibilityIdentifier("printtargLog")
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("printtargLogContainer")
|
||||
ProcessLogView(
|
||||
lines: workflow.printtargLog,
|
||||
minHeight: 100,
|
||||
maxHeight: 180,
|
||||
containerId: "printtargLogContainer",
|
||||
logId: "printtargLog"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
||||
@@ -219,18 +212,18 @@ struct Stage2View: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||
if let notice = workflow.printNotice {
|
||||
Image(systemName: workflow.printNoticeIsError
|
||||
if let notice = workflow.print.printNotice {
|
||||
Image(systemName: notice.kind == .error
|
||||
? "xmark.circle.fill" : "info.circle.fill")
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
.foregroundStyle(notice.kind == .error
|
||||
? .red : .blue)
|
||||
.accessibilityIdentifier("printNotificationIcon")
|
||||
Text(notice)
|
||||
Text(notice.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
.foregroundStyle(notice.kind == .error
|
||||
? .red : .secondary)
|
||||
.accessibilityIdentifier("printNotificationText")
|
||||
.accessibilityValue(notice)
|
||||
.accessibilityValue(notice.text)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -238,21 +231,21 @@ struct Stage2View: View {
|
||||
|
||||
// Printer row: select + status + refresh + Preferences.
|
||||
HStack(spacing: 10) {
|
||||
Picker("Printer", selection: $workflow.selectedPrinter) {
|
||||
ForEach(workflow.printers, id: \.name) { printer in
|
||||
Picker("Printer", selection: $workflow.print.selectedPrinter) {
|
||||
ForEach(workflow.print.printers, id: \.name) { printer in
|
||||
Text(printer.displayName ?? printer.name)
|
||||
.tag(printer.name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.accessibilityIdentifier("printerSelect")
|
||||
.onChange(of: workflow.selectedPrinter) { _, _ in
|
||||
workflow.selectedTray = nil
|
||||
workflow.selectedMediaType = nil
|
||||
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
||||
.onChange(of: workflow.print.selectedPrinter) { _, _ in
|
||||
workflow.print.selectedTray = nil
|
||||
workflow.print.selectedMediaType = nil
|
||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.printers
|
||||
.first(where: { $0.name == workflow.selectedPrinter }) {
|
||||
if let selected = workflow.print.printers
|
||||
.first(where: { $0.name == workflow.print.selectedPrinter }) {
|
||||
Text(selected.status.rawValue)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
@@ -260,33 +253,33 @@ struct Stage2View: View {
|
||||
.clipShape(Capsule())
|
||||
.accessibilityIdentifier("printerStatusBadge")
|
||||
}
|
||||
Button(action: workflow.refreshPrinters) {
|
||||
Button(action: workflow.print.refreshPrinters) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh printer list")
|
||||
.accessibilityIdentifier("btnRefreshPrinters")
|
||||
Button(action: workflow.openPrinterPreferences) {
|
||||
Button(action: workflow.print.openPrinterPreferences) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.help("Printer properties — bound NSPrintPanel")
|
||||
.disabled(workflow.selectedPrinter.isEmpty)
|
||||
.disabled(workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.selectedTray) {
|
||||
ForEach(workflow.printerCaps.trays, id: \.id) {
|
||||
if !workflow.print.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||
ForEach(workflow.print.printerCaps.trays, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerTraySelect")
|
||||
}
|
||||
if !workflow.printerCaps.mediaTypes.isEmpty {
|
||||
Picker("Media", selection: $workflow.selectedMediaType) {
|
||||
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
||||
if !workflow.print.printerCaps.mediaTypes.isEmpty {
|
||||
Picker("Media", selection: $workflow.print.selectedMediaType) {
|
||||
ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
@@ -296,27 +289,31 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.printOrientation = "portrait" }
|
||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
||||
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientPortrait")
|
||||
Button("Landscape") { workflow.printOrientation = "landscape" }
|
||||
Button("Landscape") { workflow.print.printOrientation = "landscape" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
||||
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientLandscape")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: workflow.printAllPages) {
|
||||
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
systemImage: "printer")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.isPrinting
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.printtargResult == nil
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintAll")
|
||||
Spacer()
|
||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||
@@ -333,8 +330,8 @@ struct Stage2View: View {
|
||||
.task(id: workflow.printtargResult?.pages.count) {
|
||||
// Auto-enumerate once a manifest exists and whenever it
|
||||
// changes (e.g. resume from .ti2).
|
||||
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
||||
workflow.refreshPrinters()
|
||||
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
|
||||
workflow.print.refreshPrinters()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,9 +361,9 @@ private struct GalleryPageView: View {
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.printPage(page) }
|
||||
.disabled(workflow.isPrinting
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
}
|
||||
.padding(8)
|
||||
|
||||
@@ -371,9 +371,9 @@ struct Stage3View: View {
|
||||
}
|
||||
|
||||
if let notice = model.finishNotice {
|
||||
Text(notice)
|
||||
Text(notice.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
|
||||
.foregroundStyle(notice.kind == .error ? .red : .green)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
||||
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
||||
struct StagePlaceholderView: View {
|
||||
let stage: WizardStage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: stage.symbolName)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text(stage.title)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("This stage is not implemented yet — see the milestone plan.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
@@ -89,30 +89,6 @@ final class TargetWorkflowViewModel {
|
||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||
var resumedFromTi2 = false
|
||||
|
||||
// MARK: - Print panel (issue 17)
|
||||
|
||||
/// CUPS destinations from `lpstat` (#printerSelect).
|
||||
var printers: [Printer] = []
|
||||
/// Selected queue name.
|
||||
var selectedPrinter = ""
|
||||
/// Capabilities of the selected queue (#printerTraySelect /
|
||||
/// #printerMediaTypeSelect / PageSize source).
|
||||
var printerCaps = PrinterCapabilities()
|
||||
var selectedTray: Int?
|
||||
var selectedMediaType: String?
|
||||
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
|
||||
var printOrientation = "portrait"
|
||||
/// Per-queue captured `key=value` strings from Preferences — replayed
|
||||
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
|
||||
var capturedCupsOptions: [String: String] = [:]
|
||||
/// In-panel notice (#printNotification) — cancel → info, not error.
|
||||
var printNotice: String?
|
||||
var printNoticeIsError = false
|
||||
var isPrinting = false
|
||||
/// Strong reference to the active print task so the unstructured
|
||||
/// `Task` is not dropped before it resumes.
|
||||
private var printTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
var presets: [ProfilingPreset] = []
|
||||
@@ -130,6 +106,8 @@ final class TargetWorkflowViewModel {
|
||||
var profile: ProfileWorkflowViewModel
|
||||
/// Stage 0 calibration workflow.
|
||||
var calibration: CalibrationViewModel!
|
||||
/// Stage 2 unmanaged print session.
|
||||
var print: PrintSessionViewModel!
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
@@ -142,6 +120,7 @@ final class TargetWorkflowViewModel {
|
||||
wizard: wizard,
|
||||
environment: environment
|
||||
)
|
||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
@@ -233,10 +212,12 @@ final class TargetWorkflowViewModel {
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.targenLog.append(contentsOf: batch)
|
||||
}
|
||||
let url = try await ProcessRunSupport.runLogged(
|
||||
setRunning: { self.targenRunning = $0 },
|
||||
resetLog: { self.targenLog = [] },
|
||||
onLog: { self.targenLog.append(contentsOf: $0) }
|
||||
) { onLog in
|
||||
try await runner.runTargen(config: config, onLogBatch: onLog)
|
||||
}
|
||||
wizard.setTarget(
|
||||
basename: config.basename,
|
||||
@@ -247,8 +228,8 @@ final class TargetWorkflowViewModel {
|
||||
} catch {
|
||||
wizard.showNotice(
|
||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||
targenRunning = false
|
||||
}
|
||||
targenRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +265,6 @@ final class TargetWorkflowViewModel {
|
||||
} 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)
|
||||
}
|
||||
@@ -366,22 +345,21 @@ final class TargetWorkflowViewModel {
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.printtargLog.append(contentsOf: batch)
|
||||
}
|
||||
let result = try await ProcessRunSupport.runLogged(
|
||||
setRunning: { self.printtargRunning = $0 },
|
||||
resetLog: { self.printtargLog = [] },
|
||||
onLog: { self.printtargLog.append(contentsOf: $0) }
|
||||
) { onLog in
|
||||
try await runner.runPrinttarg(config: config, onLogBatch: onLog)
|
||||
}
|
||||
printtargResult = result
|
||||
wizard.refreshGating()
|
||||
wizard.showNotice(
|
||||
"Layout created — \(result.manifest.pages.count) page(s) ready.")
|
||||
} catch {
|
||||
// Stay on Stage 2: non-zero exit, malformed manifest, or
|
||||
// missing .ti2 must never advance the wizard (#156).
|
||||
wizard.showNotice(
|
||||
"printtarg failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
printtargRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,160 +369,6 @@ final class TargetWorkflowViewModel {
|
||||
wizard.go(to: .measure)
|
||||
}
|
||||
|
||||
// MARK: - Print panel actions (issue 17)
|
||||
|
||||
/// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load
|
||||
/// capabilities for the selection. Auto-runs when the panel first
|
||||
/// appears with a manifest.
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let list = try await cups.listPrinters()
|
||||
printers = list
|
||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||
selectedPrinter = list.first { $0.isDefault }?.name
|
||||
?? list.first?.name ?? ""
|
||||
}
|
||||
await reloadSelectedCapabilities()
|
||||
} catch {
|
||||
printNotice = "Could not list printers: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities for `selectedPrinter` — trays / media / sizes feed
|
||||
/// the selects.
|
||||
func reloadSelectedCapabilities() async {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
printerCaps = PrinterCapabilities()
|
||||
return
|
||||
}
|
||||
do {
|
||||
printerCaps = try await environment.cupsService
|
||||
.capabilities(for: selectedPrinter)
|
||||
// Default selections only when the captured options didn't
|
||||
// already pin them (Preferences round-trip wins).
|
||||
if selectedMediaType == nil {
|
||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||
}
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings").
|
||||
/// Cancel → info notice, never an error, cache untouched. On OK the
|
||||
/// captured options are stored per-queue; a panel-side queue switch
|
||||
/// updates `printerSelect` when the returned CUPS id is in the list.
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = "Printer properties dialog cancelled."
|
||||
printNoticeIsError = false
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
printNotice = "Settings captured for \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
||||
/// the first failure so the user sees which page failed.
|
||||
func printAllPages() {
|
||||
guard let result = printtargResult, !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.printTask = nil }
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = "Print failed on \(page.page.filename): "
|
||||
+ error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
isPrinting = false
|
||||
return
|
||||
}
|
||||
}
|
||||
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
/// `#btnPrintPage-N` — one TIFF.
|
||||
func printPage(_ page: GalleryPage) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.printTask = nil }
|
||||
do {
|
||||
try await spool(page, index: page.index)
|
||||
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = "Print failed: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
isPrinting = false
|
||||
}
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
// For Stage 5 history (#95): record which queue printed.
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
func reloadPresets() {
|
||||
@@ -554,55 +378,57 @@ final class TargetWorkflowViewModel {
|
||||
/// Applies every Stage 1/2 field of the preset to the live form
|
||||
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
||||
func applyPreset(_ preset: ProfilingPreset) {
|
||||
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
|
||||
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
|
||||
customPatchCount = preset.patchCount
|
||||
whitePatches = preset.whitePatches
|
||||
blackPatches = preset.blackPatches
|
||||
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
|
||||
singleChannelSteps = preset.singleChannelSteps ?? 5
|
||||
singleChannelEnabled = preset.singleChannelSteps != nil
|
||||
neutralSteps = preset.neutralSteps ?? 3
|
||||
neutralStepsEnabled = preset.neutralSteps != nil
|
||||
neutralConcentration = preset.neutralConcentration ?? 0.50
|
||||
neutralConcEnabled = preset.neutralConcentration != nil
|
||||
preconditioningProfile = preset.preconditioningProfile
|
||||
highQuality = preset.ofpsHighQuality == true
|
||||
adaptation = preset.ofpsAdaptation ?? 0.10
|
||||
adaptationEnabled = preset.ofpsAdaptation != nil
|
||||
algorithm = preset.fullSpreadAlgorithm
|
||||
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
|
||||
totalInkLimit = preset.totalInkLimit ?? 320
|
||||
inkLimitEnabled = preset.totalInkLimit != nil
|
||||
darkEmphasis = preset.darkEmphasis ?? 1.0
|
||||
darkEmphasisEnabled = preset.darkEmphasis != nil
|
||||
devicePower = preset.devicePower ?? 1.0
|
||||
devicePowerEnabled = preset.devicePower != nil
|
||||
|
||||
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
|
||||
if let size = PageSize(rawValue: preset.pageSize) {
|
||||
pageSize = size
|
||||
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
|
||||
pageSize = .custom; customPageW = w; customPageH = h
|
||||
} else {
|
||||
pageSize = .a4
|
||||
}
|
||||
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
|
||||
tiffDpi = preset.dpi
|
||||
if preset.noRandomize == true {
|
||||
layoutOrder = .raster
|
||||
} else if (preset.randomSeed ?? 1) == 1 {
|
||||
layoutOrder = .deterministic
|
||||
} else {
|
||||
layoutOrder = .customSeed
|
||||
}
|
||||
customSeed = preset.randomSeed ?? 1
|
||||
|
||||
let targen = TargenConfig(preset: preset, basename: targetBasename, workingDirectory: targetDirectory)
|
||||
applyTargenForm(targen)
|
||||
let printtarg = PrinttargConfig(
|
||||
preset: preset,
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory,
|
||||
calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil
|
||||
)
|
||||
applyPrinttargForm(printtarg)
|
||||
profile.applyPreset(preset)
|
||||
|
||||
selectedPresetID = preset.id
|
||||
}
|
||||
|
||||
private func applyTargenForm(_ config: TargenConfig) {
|
||||
colourSpace = config.colourSpace
|
||||
patchPreset = PatchCountPreset(rawValue: "\(config.patchCount)") ?? .custom
|
||||
customPatchCount = config.patchCount
|
||||
whitePatches = config.whitePatches
|
||||
blackPatches = config.blackPatches
|
||||
greySteps = config.greySteps ?? 5
|
||||
greyStepsEnabled = config.greySteps != nil
|
||||
singleChannelSteps = config.singleChannelSteps ?? 5
|
||||
singleChannelEnabled = config.singleChannelSteps != nil
|
||||
neutralSteps = config.neutralSteps ?? 3
|
||||
neutralStepsEnabled = config.neutralSteps != nil
|
||||
neutralConcentration = config.neutralConcentration ?? 0.50
|
||||
neutralConcEnabled = config.neutralConcentration != nil
|
||||
preconditioningProfile = config.preconditioningProfile
|
||||
highQuality = config.ofpsHighQuality == true
|
||||
adaptation = config.ofpsAdaptation ?? 0.10
|
||||
adaptationEnabled = config.ofpsAdaptation != nil
|
||||
algorithm = config.fullSpreadAlgorithm ?? .ofps
|
||||
totalInkLimit = config.totalInkLimit ?? 320
|
||||
inkLimitEnabled = config.totalInkLimit != nil
|
||||
darkEmphasis = config.darkEmphasis ?? 1.0
|
||||
darkEmphasisEnabled = config.darkEmphasis != nil
|
||||
devicePower = config.devicePower ?? 1.0
|
||||
devicePowerEnabled = config.devicePower != nil
|
||||
}
|
||||
|
||||
private func applyPrinttargForm(_ config: PrinttargConfig) {
|
||||
instrument = config.instrument
|
||||
pageSize = config.pageSize
|
||||
customPageW = config.customPageWidth
|
||||
customPageH = config.customPageHeight
|
||||
bitDepth = config.bitDepth
|
||||
tiffDpi = config.dpi
|
||||
layoutOrder = config.layoutOrder
|
||||
customSeed = config.customSeed
|
||||
}
|
||||
|
||||
/// Snapshot of the live Stage 1/2 form as a custom preset.
|
||||
func saveCurrentAsPreset() {
|
||||
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -614,39 +440,11 @@ final class TargetWorkflowViewModel {
|
||||
id: "custom-\(UUID().uuidString.lowercased())",
|
||||
name: name,
|
||||
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||
patchCount: effectivePatchCount,
|
||||
whitePatches: whitePatches,
|
||||
blackPatches: blackPatches,
|
||||
greySteps: greyStepsEnabled ? greySteps : nil,
|
||||
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
|
||||
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
|
||||
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
|
||||
preconditioningProfile: preconditioningProfile,
|
||||
ofpsHighQuality: highQuality ? true : nil,
|
||||
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
|
||||
fullSpreadAlgorithm: algorithm.presetValue,
|
||||
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
||||
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
||||
devicePower: devicePowerEnabled ? devicePower : nil,
|
||||
instrument: instrument.rawValue,
|
||||
pageSize: pageSize == .custom
|
||||
? "\(Int(customPageW))x\(Int(customPageH))"
|
||||
: pageSize.rawValue,
|
||||
bitDepth: bitDepth.rawValue,
|
||||
dpi: tiffDpi,
|
||||
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
|
||||
noRandomize: layoutOrder == .raster,
|
||||
targen: buildTargenConfig(),
|
||||
printtarg: buildPrinttargConfig(),
|
||||
colprof: profile.buildColprofConfig(),
|
||||
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
|
||||
applyCalibration: profile.applyCalibration ? true : nil,
|
||||
colprofAlgorithm: profile.algorithm,
|
||||
colprofQuality: profile.quality,
|
||||
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
|
||||
colprofFwa: profile.fwaValue,
|
||||
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
|
||||
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
|
||||
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
|
||||
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
|
||||
applyCalibration: profile.applyCalibration ? true : nil
|
||||
)
|
||||
do {
|
||||
try environment.presetStore.saveCustom(preset)
|
||||
@@ -710,10 +508,6 @@ final class TargetWorkflowViewModel {
|
||||
}
|
||||
|
||||
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
|
||||
let parts = raw.lowercased().split(separator: "x")
|
||||
guard parts.count == 2,
|
||||
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||
w >= 50, h >= 50 else { return nil }
|
||||
return (w, h)
|
||||
PageSize.parseCustom(raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,12 @@ final class WizardViewModel {
|
||||
self.calibrationOriginalBasename = s.calibrationOriginalBasename
|
||||
// A Force Quit mid-calibration leaves a CAL_ basename behind; restore
|
||||
// the original before the UI can do anything with it (#29).
|
||||
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
|
||||
basename = calibrationOriginalBasename
|
||||
if CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
|
||||
let identity = CalibrationIdentity.parse(
|
||||
liveBasename: basename,
|
||||
persistedOriginal: calibrationOriginalBasename
|
||||
)
|
||||
basename = identity.originalBasename
|
||||
calibrationOriginalBasename = ""
|
||||
sessionMode = .profile
|
||||
stage = .generate
|
||||
@@ -128,7 +132,7 @@ final class WizardViewModel {
|
||||
/// refused and the original basename is restored (#29).
|
||||
func go(to target: WizardStage) {
|
||||
guard target != .calibrate else { enterCalibration(); return }
|
||||
if basename.hasPrefix("CAL_") {
|
||||
if CalibrationIdentity.isCalibration(basename) {
|
||||
guard !calibrationOriginalBasename.isEmpty else {
|
||||
showNotice(
|
||||
"Cannot leave calibration — the original target name is missing.",
|
||||
@@ -152,7 +156,7 @@ final class WizardViewModel {
|
||||
}
|
||||
|
||||
func enterCalibration() {
|
||||
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
|
||||
if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
|
||||
calibrationOriginalBasename = basename
|
||||
}
|
||||
sessionMode = .calibration
|
||||
|
||||
@@ -122,3 +122,29 @@ struct ArtefactFilesTests {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArtefactProbe profile resolve")
|
||||
struct ArtefactProbeProfileTests {
|
||||
@Test("basename probe prefers .icm")
|
||||
func icmWins() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
|
||||
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
|
||||
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
|
||||
#expect(url?.pathExtension == "icm")
|
||||
}
|
||||
|
||||
@Test("explicit missing .icc flips to sibling .icm")
|
||||
func flipExtension() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("probe-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let icc = dir.appendingPathComponent("job.icc")
|
||||
let icm = dir.appendingPathComponent("job.icm")
|
||||
try Data("icm".utf8).write(to: icm)
|
||||
let resolved = ArtefactProbe.resolveProfile(icc)
|
||||
#expect(resolved.path == icm.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("JSONFileStore")
|
||||
struct JSONFileStoreTests {
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
@Test("Corrupt file with replaceWithDefault returns default")
|
||||
func corruptDefaults() throws {
|
||||
let url = tempURL()
|
||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let store = JSONFileStore<AppSettings>(
|
||||
fileURL: url,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
#expect(try store.load() == .default)
|
||||
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
||||
func corruptThrows() throws {
|
||||
let url = tempURL()
|
||||
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
let store = JSONFileStore<[Int]>(
|
||||
fileURL: url,
|
||||
corrupt: .throwCorrupt,
|
||||
defaultValue: { [] }
|
||||
)
|
||||
#expect(throws: DecodingError.self) {
|
||||
_ = try store.load()
|
||||
}
|
||||
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||
#expect(kept == "not json")
|
||||
}
|
||||
|
||||
@Test("Pretty sorted keys")
|
||||
func prettySorted() throws {
|
||||
let url = tempURL()
|
||||
let store = JSONFileStore<AppSettings>(
|
||||
fileURL: url,
|
||||
corrupt: .replaceWithDefault,
|
||||
defaultValue: { .default }
|
||||
)
|
||||
try store.save(.default)
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
#expect(text.contains("\n"))
|
||||
#expect(text.contains("\"delta_e_good_max\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CalibrationIdentity")
|
||||
struct CalibrationIdentityTests {
|
||||
@Test("live foo, no persisted")
|
||||
func livePlain() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
|
||||
#expect(id.originalBasename == "foo")
|
||||
#expect(id.calibrationBasename == "CAL_foo")
|
||||
}
|
||||
|
||||
@Test("live CAL_foo, persisted foo")
|
||||
func liveCalPersisted() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
|
||||
#expect(id.originalBasename == "foo")
|
||||
#expect(id.calibrationBasename == "CAL_foo")
|
||||
}
|
||||
|
||||
@Test("live CAL_foo, empty persisted strips prefix")
|
||||
func liveCalNoPersist() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
|
||||
#expect(id.originalBasename == "foo")
|
||||
#expect(id.calibrationBasename == "CAL_foo")
|
||||
}
|
||||
|
||||
@Test("persisted original wins")
|
||||
func persistedWins() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
|
||||
#expect(id.originalBasename == "bar")
|
||||
#expect(id.calibrationBasename == "CAL_bar")
|
||||
}
|
||||
|
||||
@Test("empty live does not invent a name")
|
||||
func emptyLive() {
|
||||
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
|
||||
#expect(id.originalBasename.isEmpty)
|
||||
#expect(id.calibrationBasename.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -230,3 +230,61 @@ struct PresetMigrationTests {
|
||||
#expect(back.dpi == 150)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Preset mapping")
|
||||
struct PresetMappingTests {
|
||||
@Test("Draft 150 DPI maps into PrinttargConfig")
|
||||
func draftDpi() {
|
||||
let cfg = PrinttargConfig(
|
||||
preset: PresetCatalog.draftRGB,
|
||||
basename: "t",
|
||||
workingDirectory: nil,
|
||||
calibrationFile: nil
|
||||
)
|
||||
#expect(cfg.dpi == 150)
|
||||
#expect(cfg.layoutOrder == .deterministic)
|
||||
}
|
||||
|
||||
@Test("Nil optional targen fields stay nil")
|
||||
func optionalNil() {
|
||||
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
|
||||
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
|
||||
#expect(cfg.greySteps == nil)
|
||||
#expect(cfg.singleChannelSteps == nil)
|
||||
#expect(cfg.neutralSteps == nil)
|
||||
#expect(cfg.totalInkLimit == nil)
|
||||
#expect(cfg.darkEmphasis == nil)
|
||||
#expect(cfg.devicePower == nil)
|
||||
}
|
||||
|
||||
@Test("Custom page and FWA survive a config round-trip")
|
||||
func roundTripConfigs() {
|
||||
var preset = PresetCatalog.highQualityCMYK
|
||||
preset.pageSize = "210x297"
|
||||
preset.colprofFwa = "D50"
|
||||
preset.greySteps = nil
|
||||
let targen = TargenConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||
let printtarg = PrinttargConfig(
|
||||
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
|
||||
)
|
||||
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
|
||||
#expect(printtarg.pageSize == .custom)
|
||||
#expect(printtarg.customPageWidth == 210)
|
||||
#expect(colprof.fwa == "D50")
|
||||
let back = ProfilingPreset(
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
targen: targen,
|
||||
printtarg: printtarg,
|
||||
colprof: colprof,
|
||||
calibrationFile: preset.calibrationFile,
|
||||
applyCalibration: preset.applyCalibration
|
||||
)
|
||||
#expect(back.dpi == preset.dpi)
|
||||
#expect(back.colourSpace == "cmyk")
|
||||
#expect(back.pageSize == "210x297")
|
||||
#expect(back.colprofFwa == "D50")
|
||||
#expect(back.greySteps == nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user