diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md index 2896da5..e2e4565 100644 --- a/BUILD-PLAN.md +++ b/BUILD-PLAN.md @@ -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.** diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift new file mode 100644 index 0000000..c858a06 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgsBuilder.swift @@ -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] : [] + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index d3a7c29..e3aa959 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -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] ) } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift index bd4280c..2d6c08b 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift @@ -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]) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift index 2affd02..9bb9777 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/ArtefactProbe.swift @@ -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" diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift new file mode 100644 index 0000000..3da31f3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift @@ -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: 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 + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift index d9b7692..6bfd766 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ChartreadRow.swift @@ -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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift index 02cf8b4..4ac782b 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/ColorDifference.swift @@ -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 { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift index cd7a427..c795b30 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Measurement/LabColor.swift @@ -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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index 627bda6..0d7381b 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -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() { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift new file mode 100644 index 0000000..ac24329 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationIdentity.swift @@ -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) + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift index bccaa02..e5d4b4a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift @@ -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 } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift index 05d4566..ba01450 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/ColprofArgs.swift @@ -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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift index bdee5e2..6c8c5fa 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift @@ -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 } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift index 2910aca..fea54c9 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift @@ -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 { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift new file mode 100644 index 0000000..f7f36dc --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetMapping.swift @@ -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 + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift index eec99f3..b47d0b3 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift @@ -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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift index f4a679c..22bdca6 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift @@ -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 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) } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift index e219f5e..b5fb31f 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift @@ -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 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) } } diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift index 8ed2f17..a327019 100644 --- a/Sources/ICCery/CalibrationViewModel.swift +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -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 diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index 13e7c0c..5852e93 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -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?, diff --git a/Sources/ICCery/MeasurementWorkflowViewModel.swift b/Sources/ICCery/MeasurementWorkflowViewModel.swift index 541608d..688ae3a 100644 --- a/Sources/ICCery/MeasurementWorkflowViewModel.swift +++ b/Sources/ICCery/MeasurementWorkflowViewModel.swift @@ -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 diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift new file mode 100644 index 0000000..b35cf99 --- /dev/null +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -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? + + 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 + } +} diff --git a/Sources/ICCery/ProcessLogView.swift b/Sources/ICCery/ProcessLogView.swift new file mode 100644 index 0000000..dfdd216 --- /dev/null +++ b/Sources/ICCery/ProcessLogView.swift @@ -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) + } +} diff --git a/Sources/ICCery/ProcessRunSupport.swift b/Sources/ICCery/ProcessRunSupport.swift new file mode 100644 index 0000000..0302d02 --- /dev/null +++ b/Sources/ICCery/ProcessRunSupport.swift @@ -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( + 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)) + } +} diff --git a/Sources/ICCery/ProfileWorkflowViewModel.swift b/Sources/ICCery/ProfileWorkflowViewModel.swift index 8083ead..589ed21 100644 --- a/Sources/ICCery/ProfileWorkflowViewModel.swift +++ b/Sources/ICCery/ProfileWorkflowViewModel.swift @@ -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) diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 2f9ab9a..cfedcb1 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -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) } } } diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 503f08e..349b100 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -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" + ) } } diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 5f79f2b..d201198 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -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) diff --git a/Sources/ICCery/Stage3View.swift b/Sources/ICCery/Stage3View.swift index 290b2d3..1c9aa2c 100644 --- a/Sources/ICCery/Stage3View.swift +++ b/Sources/ICCery/Stage3View.swift @@ -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) diff --git a/Sources/ICCery/StagePlaceholderView.swift b/Sources/ICCery/StagePlaceholderView.swift deleted file mode 100644 index ae92b96..0000000 --- a/Sources/ICCery/StagePlaceholderView.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 689012d..52159d2 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -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? - // 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) } } diff --git a/Sources/ICCery/WizardViewModel.swift b/Sources/ICCery/WizardViewModel.swift index 1cbac02..2bf9747 100644 --- a/Sources/ICCery/WizardViewModel.swift +++ b/Sources/ICCery/WizardViewModel.swift @@ -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 diff --git a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift index 9a8b591..439dfce 100644 --- a/Tests/ICCeryCoreTests/ArtefactFilesTests.swift +++ b/Tests/ICCeryCoreTests/ArtefactFilesTests.swift @@ -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) + } +} diff --git a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift new file mode 100644 index 0000000..b9f20c2 --- /dev/null +++ b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift @@ -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( + 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( + 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) + } +} diff --git a/Tests/ICCeryCoreTests/PresetTests.swift b/Tests/ICCeryCoreTests/PresetTests.swift index 0e8c519..eaf482b 100644 --- a/Tests/ICCeryCoreTests/PresetTests.swift +++ b/Tests/ICCeryCoreTests/PresetTests.swift @@ -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) + } +}