Author SHA1 Message Date
gronod ac2ecbdc40 Merge branch 'develop' into fix/m7-ci-uitest-runner-sign
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
2026-09-10 09:01:24 +01:00
56 changed files with 830 additions and 1435 deletions
-13
View File
@@ -67,21 +67,8 @@ jobs:
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Prepare Node CA bundle
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
/System/Library/Keychains/SystemRootCertificates.keychain \
/Library/Keychains/System.keychain \
> "$NODE_CA_FILE" 2>/dev/null || true
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
fi
- name: Upload DMG artifact
uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
with:
name: iccery-dmg
path: ICCery-*.dmg
-1
View File
@@ -27,4 +27,3 @@ ICCery.xcodeproj/
Release/
notarization/
build/
docs/megaplans/
-1
View File
@@ -16,7 +16,6 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 7986 | 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.**
@@ -1,35 +0,0 @@
import Foundation
/// Tiny argv helpers. Each Argyll tool keeps its own `*Args` enum
/// `-d` / `-u` / `-r` still mean different things per binary.
public enum ArgsBuilder {
/// `["-f", value]` when `value` is non-nil.
public static func option(_ flag: String, _ value: String?) -> [String] {
guard let value else { return [] }
return [flag, value]
}
/// `["-f", trimmed]` when trimmed is non-empty.
public static func optionIfNonEmpty(_ flag: String, _ value: String?) -> [String] {
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else { return [] }
return [flag, raw]
}
/// Omits the flag when `value` is nil or within `epsilon` of `skip`.
public static func optionUnlessApprox(
_ flag: String,
_ value: Double?,
skip: Double,
epsilon: Double = 0.001,
format: String = "%.2f"
) -> [String] {
guard let value, abs(value - skip) >= epsilon else { return [] }
return [flag, String(format: format, locale: Locale(identifier: "en_US_POSIX"), value)]
}
/// Bare flag when `when` is true.
public static func flag(_ flag: String, when: Bool) -> [String] {
when ? [flag] : []
}
}
@@ -2,41 +2,43 @@ import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case toolFailed(tool: String, code: Int32, logs: [String])
case processFailed(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 .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 .processFailed(let code, _):
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"
}
@@ -74,48 +76,6 @@ 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`
@@ -127,16 +87,27 @@ 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)
_ = try await runStreamingTool(
name: "targen",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
return try requireArtefact(ti1URL)
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
// MARK: - printtarg (Stage 2)
@@ -151,15 +122,26 @@ 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)
let run = try await runStreamingTool(
name: "printtarg",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let ti2URL = try requireArtefact(cwd.appendingPathComponent("\(cleanBasename).ti2"))
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 manifest: PrinttargManifest
do {
@@ -357,16 +339,28 @@ 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)
_ = try await runStreamingTool(
name: "average",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
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")
return try requireArtefact(canonical)
guard FileManager.default.fileExists(atPath: canonical.path) else {
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
}
// MARK: - colprof (Stage 4)
@@ -380,15 +374,29 @@ 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)
_ = try await runStreamingTool(
name: "colprof",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
flushPartialLines: true,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
@@ -448,20 +456,16 @@ public struct ArgyllRunner: Sendable {
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: result.exitCode,
logs: [result.stderr.isEmpty
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)"
: result.stderr]
: result.stderr
)
}
guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: ["applycal did not create temp profile"]
throw ArgyllRunnerError.applycalFailed(
"applycal did not create temp profile"
)
}
@@ -469,10 +473,8 @@ public struct ArgyllRunner: Sendable {
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: ["calibrated profile is too small (\(size) bytes)"]
throw ArgyllRunnerError.applycalFailed(
"calibrated profile is too small (\(size) bytes)"
)
}
@@ -484,11 +486,7 @@ public struct ArgyllRunner: Sendable {
}
} catch {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.toolFailed(
tool: "applycal",
code: -1,
logs: [error.localizedDescription]
)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
}
return inputURL
@@ -505,16 +503,30 @@ 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)
_ = try await runStreamingTool(
name: "iccgamut",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam")
return try requireArtefact(gamURL)
guard FileManager.default.fileExists(atPath: gamURL.path) else {
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
}
// MARK: - profcheck (Stage 5 verification)
@@ -527,18 +539,30 @@ public struct ArgyllRunner: Sendable {
let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path
let iccURL = ArtefactProbe.resolveProfile(config.iccURL)
let iccURL = Self.resolveProfileForVerification(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
let run = try await runStreamingTool(
name: "profcheck",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output)
@@ -548,6 +572,15 @@ 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.
@@ -563,7 +596,7 @@ public struct ArgyllRunner: Sendable {
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
@@ -573,7 +606,7 @@ public struct ArgyllRunner: Sendable {
args = try ChartreadArgs.build(config: config)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
@@ -604,7 +637,7 @@ public struct ArgyllRunner: Sendable {
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(tool: "chartread", code: -1, logs: [error.localizedDescription])))
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
return
}
@@ -691,11 +724,7 @@ public struct ArgyllRunner: Sendable {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
}
} else {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "chartread",
code: exitCode ?? -1,
logs: ["chartread exited with code \(exitCode ?? -1)"]
)))
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
}
continuation.finish()
}
@@ -739,19 +768,30 @@ public struct ArgyllRunner: Sendable {
) async throws -> URL {
let args = try CalibrationTargenArgs.build(config: config)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let cleanBasename = try PathSecurity.sanitizeBasename(
CalibrationIdentity.prefix(config.basename)
)
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)
_ = try await runStreamingTool(
name: "targen",
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd,
onLogBatch: onLogBatch
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
return try requireArtefact(ti1URL)
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
/// Computes a `.cal` curve from a measured `CAL_*.ti3`.
@@ -765,7 +805,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 = CalibrationIdentity.prefix(config.ti3Basename)
let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)"
let processId = ProcessID.printcal(calBasename)
await ensureNotRunning(id: processId)
@@ -781,12 +821,10 @@ public struct ArgyllRunner: Sendable {
}
guard result.exitCode == 0 else {
throw ArgyllRunnerError.toolFailed(
tool: "printcal",
code: result.exitCode,
logs: [result.stderr.isEmpty
throw ArgyllRunnerError.printcalFailed(
result.stderr.isEmpty
? "printcal exited with code \(result.exitCode)"
: result.stderr]
: result.stderr
)
}
@@ -69,7 +69,7 @@ public enum PrinttargArgs {
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !CalibrationIdentity.isCalibration(cleanBasename),
if !cleanBasename.hasPrefix("CAL_"),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
@@ -78,18 +78,6 @@ 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"
@@ -1,84 +0,0 @@
import Foundation
/// Policy when a JSON file exists but cannot be decoded.
public enum JSONCorruptPolicy: Sendable {
/// Return `defaultValue` and leave the file untouched.
case replaceWithDefault
/// Throw the decode error. Callers must not overwrite the file.
case throwCorrupt
}
/// Shared pretty-printed JSON file façade used by settings, wizard state,
/// and verification history.
public struct JSONFileStore<T: Codable & Sendable>: Sendable {
public let fileURL: URL
public let corrupt: JSONCorruptPolicy
private let defaultValue: @Sendable () -> T
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
fileURL: URL,
corrupt: JSONCorruptPolicy,
defaultValue: @escaping @Sendable () -> T,
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate,
dateDecoding: JSONDecoder.DateDecodingStrategy = .deferredToDate
) {
self.fileURL = fileURL
self.corrupt = corrupt
self.defaultValue = defaultValue
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = dateEncoding
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
self.decoder = decoder
}
/// Encodes `value` with the shared pretty / sorted-keys encoder.
public func encodePretty(_ value: T) throws -> Data {
try encoder.encode(value)
}
public func load() throws -> T {
let fm = FileManager.default
guard fm.fileExists(atPath: fileURL.path) else {
return defaultValue()
}
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
switch corrupt {
case .replaceWithDefault:
return defaultValue()
case .throwCorrupt:
throw error
}
}
}
public func save(_ value: T) throws {
try AtomicFileWriter.write(try encodePretty(value), to: fileURL)
}
}
extension JSONEncoder {
/// Pretty-printed, sorted-keys encoder used by preset export.
public static func icceryPretty() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
@@ -51,6 +51,58 @@ public struct PatchColor: Codable, Sendable, Equatable {
}
}
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int
public let startNM: Double
@@ -154,9 +154,11 @@ 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 lab }
if let lab = color.lab {
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(xyz)
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
}
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
@@ -1,8 +1,7 @@
import Foundation
/// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork.
/// Unkeyed Codable matches `ROW_COLORS_JSON` `[x, y, z]`.
public struct XYZColor: Codable, Sendable, Equatable {
public struct XYZColor: Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
@@ -12,24 +11,10 @@ public struct XYZColor: Codable, 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). Unkeyed Codable matches `ROW_COLORS_JSON` `[L, a, b]`.
public struct LabColor: Codable, Sendable, Equatable {
/// CIELab value (D50).
public struct LabColor: Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
@@ -39,26 +24,8 @@ public struct LabColor: Codable, 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 01 display space.
public struct DisplayRGB: Sendable, Equatable {
public let r: Double
@@ -136,14 +136,17 @@ public actor ProcessManager {
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let prepared = makeProcess(
binary: binary,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
includeStdin: true
)
let process = prepared.process
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)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
@@ -151,13 +154,13 @@ public actor ProcessManager {
children[id] = RunningChild(
process: process,
stdin: prepared.stdinPipe?.fileHandleForWriting,
stdin: stdinPipe.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
let stdoutHandle = prepared.stdoutPipe.fileHandleForReading
let stderrHandle = prepared.stderrPipe.fileHandleForReading
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
@@ -169,13 +172,21 @@ public actor ProcessManager {
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
attachExitWatchdog(process) { [weak self] code in
process.terminationHandler = { [weak self] proc in
guard let self else { return }
Task { await self.didTerminate(id: id, code: code) }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
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)
@@ -199,16 +210,15 @@ public actor ProcessManager {
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
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
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)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
@@ -265,12 +275,20 @@ public actor ProcessManager {
}
}
let box = Box()
attachExitWatchdog(capturedProcess) { status in
_ = box.resume(with: status)
capturedProcess.terminationHandler = { proc in
_ = box.resume(with: proc.terminationStatus)
}
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)
@@ -353,7 +371,12 @@ public actor ProcessManager {
guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() {
emitStdoutLine(id: id, line: tail)
if tail.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
}
if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail))
@@ -424,65 +447,6 @@ 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"
@@ -514,14 +478,15 @@ public actor ProcessManager {
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr {
emitStdoutLine(id: id, line: line)
if !line.hasPrefix(Self.rowColorsPrefix) {
log.info("[\(id)] \(line)")
}
} else {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
@@ -563,7 +528,11 @@ public actor ProcessManager {
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
emitStdoutLine(id: id, line: tail)
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
@@ -1,54 +0,0 @@
import Foundation
/// Canonical `CAL_` / original-stem pairing for Stage 0 (issue #29 / #83).
///
/// The live wizard basename, the persisted `calibrationOriginalBasename`,
/// and the runner all derive identity from this type. Do not add a second
/// `hasPrefix("CAL_")` ternary elsewhere.
public struct CalibrationIdentity: Equatable, Sendable {
/// Never has a `CAL_` prefix. Empty only when the live basename is empty.
public var originalBasename: String
/// Always `CAL_{original}` when original is non-empty.
public var calibrationBasename: String
public init(originalBasename: String, calibrationBasename: String) {
self.originalBasename = originalBasename
self.calibrationBasename = calibrationBasename
}
public static func isCalibration(_ basename: String) -> Bool {
basename.hasPrefix("CAL_")
}
/// The only place that adds a `CAL_` prefix.
public static func prefix(_ original: String) -> String {
if original.isEmpty { return original }
return original.hasPrefix("CAL_") ? original : "CAL_\(original)"
}
/// Strip a single leading `CAL_` if present.
public static func strip(_ basename: String) -> String {
basename.hasPrefix("CAL_") ? String(basename.dropFirst(4)) : basename
}
/// Derive identity from the live wizard basename and the persisted
/// original. A non-empty persisted original wins over a `CAL_` live
/// name (Force Quit mid-calibration).
public static func parse(liveBasename: String, persistedOriginal: String) -> CalibrationIdentity {
if liveBasename.isEmpty && persistedOriginal.isEmpty {
return CalibrationIdentity(originalBasename: "", calibrationBasename: "")
}
let original: String
if liveBasename.hasPrefix("CAL_") {
original = persistedOriginal.isEmpty ? strip(liveBasename) : persistedOriginal
} else if liveBasename.isEmpty {
original = persistedOriginal
} else {
original = liveBasename
}
return CalibrationIdentity(
originalBasename: original,
calibrationBasename: prefix(original)
)
}
}
@@ -86,7 +86,7 @@ public enum CalibrationTargenArgs {
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
let calBasename = CalibrationIdentity.prefix(cleanBasename)
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
@@ -26,13 +26,18 @@ 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])
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-t", config.intent))
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() {
case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty {
args.append("-f")
}
@@ -41,20 +46,32 @@ public enum ColprofArgs {
}
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-i", config.illuminant))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-o", config.observer))
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
args.append(contentsOf: ["-i", illuminant])
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond])
}
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond])
}
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-D", config.description))
args.append(contentsOf: ArgsBuilder.optionIfNonEmpty("-C", config.copyright))
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
if let description = profileDescription, !description.isEmpty {
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename)
return args
@@ -107,7 +107,7 @@ public enum PrintcalArgs {
}
args.append(contentsOf: ["-o", config.outputURL.path])
let calBasename = CalibrationIdentity.prefix(cleanBasename)
let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)"
args.append(calBasename)
return args
}
@@ -13,7 +13,8 @@ public actor VerificationHistoryStore {
private var records: [VerificationRecord] = []
private let capacity: Int
private let fileStore: JSONFileStore<[VerificationRecord]>
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
@@ -21,13 +22,13 @@ public actor VerificationHistoryStore {
) {
self.url = url
self.capacity = capacity
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
}
/// Loads records from disk. Returns the existing cache if already loaded.
@@ -36,8 +37,10 @@ public actor VerificationHistoryStore {
/// is never overwritten in that case.
public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records }
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
records = try fileStore.load()
let fm = FileManager.default
guard fm.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records
}
@@ -103,7 +106,8 @@ public actor VerificationHistoryStore {
/// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws {
try fileStore.save(records)
let data = try encoder.encode(records)
try AtomicFileWriter.write(data, to: url)
}
private func csvRow(_ fields: [String]) -> String {
@@ -1,178 +0,0 @@
import Foundation
extension TargenConfig {
/// Stage 1 fields of a profiling preset. Optional advanced flags stay
/// `nil` when the preset omitted them so argv builders skip the flag.
public init(preset: ProfilingPreset, basename: String, workingDirectory: URL?) {
self.init(
colourSpace: preset.colourSpace.lowercased() == "cmyk" ? .cmyk : .rgb,
patchCount: preset.patchCount,
whitePatches: preset.whitePatches,
blackPatches: preset.blackPatches,
greySteps: preset.greySteps,
singleChannelSteps: preset.singleChannelSteps,
neutralSteps: preset.neutralSteps,
neutralConcentration: preset.neutralConcentration,
preconditioningProfile: preset.preconditioningProfile,
ofpsHighQuality: preset.ofpsHighQuality == true ? true : nil,
ofpsAdaptation: preset.ofpsAdaptation,
fullSpreadAlgorithm: preset.fullSpreadAlgorithm.flatMap { FullSpreadAlgorithm(presetValue: $0) }.flatMap { $0 == .ofps ? nil : $0 },
totalInkLimit: preset.totalInkLimit,
darkEmphasis: preset.darkEmphasis,
devicePower: preset.devicePower,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension PrinttargConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
calibrationFile: String?,
label: String? = nil
) {
let page: PageSize
let customW: Double
let customH: Double
if let size = PageSize(rawValue: preset.pageSize) {
page = size
customW = 210
customH = 297
} else if let (w, h) = PageSize.parseCustom(preset.pageSize) {
page = .custom
customW = w
customH = h
} else {
page = .a4
customW = 210
customH = 297
}
let layout: LayoutOrder
let seed = preset.randomSeed ?? 1
if preset.noRandomize == true {
layout = .raster
} else if seed == 1 {
layout = .deterministic
} else {
layout = .customSeed
}
self.init(
instrument: PrintInstrument(rawValue: preset.instrument) ?? .i1,
pageSize: page,
customPageWidth: customW,
customPageHeight: customH,
bitDepth: preset.bitDepth == 16 ? .sixteen : .eight,
dpi: preset.dpi,
layoutOrder: layout,
customSeed: seed,
label: label,
calibrationFile: calibrationFile,
calibrationEmbedOnly: false,
basename: basename,
workingDirectory: workingDirectory
)
}
}
extension ColprofConfig {
public init(
preset: ProfilingPreset,
basename: String,
workingDirectory: URL?,
description: String? = nil,
copyright: String? = nil
) {
self.init(
algorithm: preset.colprofAlgorithm ?? "l",
quality: preset.colprofQuality ?? "m",
intent: Self.nilIfEmpty(preset.colprofIntent),
fwa: preset.colprofFwa,
illuminant: Self.nilIfEmpty(preset.colprofIlluminant),
observer: Self.nilIfEmpty(preset.colprofObserver),
inputViewingCond: Self.nilIfEmpty(preset.colprofInputViewingCond),
outputViewingCond: Self.nilIfEmpty(preset.colprofOutputViewingCond),
description: description,
copyright: copyright,
basename: basename,
workingDirectory: workingDirectory
)
}
private static func nilIfEmpty(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
}
extension PageSize {
/// `"210x297"` custom page parse used by presets (issue #82).
public static func parseCustom(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
}
}
extension ProfilingPreset {
/// Snapshot of the three live configs plus calibration toggles.
public init(
id: String,
name: String,
description: String,
targen: TargenConfig,
printtarg: PrinttargConfig,
colprof: ColprofConfig,
calibrationFile: String?,
applyCalibration: Bool?
) {
let pageSize: String
if printtarg.pageSize == .custom {
pageSize = "\(Int(printtarg.customPageWidth))x\(Int(printtarg.customPageHeight))"
} else {
pageSize = printtarg.pageSize.rawValue
}
self.init(
id: id,
name: name,
description: description,
colourSpace: targen.colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: targen.patchCount,
whitePatches: targen.whitePatches,
blackPatches: targen.blackPatches,
greySteps: targen.greySteps,
singleChannelSteps: targen.singleChannelSteps,
neutralSteps: targen.neutralSteps,
neutralConcentration: targen.neutralConcentration,
preconditioningProfile: targen.preconditioningProfile,
ofpsHighQuality: targen.ofpsHighQuality,
ofpsAdaptation: targen.ofpsAdaptation,
fullSpreadAlgorithm: (targen.fullSpreadAlgorithm ?? .ofps).presetValue,
totalInkLimit: targen.totalInkLimit,
darkEmphasis: targen.darkEmphasis,
devicePower: targen.devicePower,
instrument: printtarg.instrument.rawValue,
pageSize: pageSize,
bitDepth: printtarg.bitDepth.rawValue,
dpi: printtarg.dpi,
randomSeed: printtarg.layoutOrder == .deterministic ? 1 : printtarg.customSeed,
noRandomize: printtarg.layoutOrder == .raster,
calibrationFile: calibrationFile,
applyCalibration: applyCalibration,
colprofAlgorithm: colprof.algorithm,
colprofQuality: colprof.quality,
colprofIntent: colprof.intent,
colprofFwa: colprof.fwa,
colprofIlluminant: colprof.illuminant,
colprofObserver: colprof.observer,
colprofInputViewingCond: colprof.inputViewingCond,
colprofOutputViewingCond: colprof.outputViewingCond
)
}
}
@@ -60,7 +60,9 @@ public final class PresetStore: Sendable {
/// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data {
return try JSONEncoder.icceryPretty().encode(preset)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
}
/// Parses + validates a preset from JSON. The preset is assigned a
@@ -4,8 +4,8 @@ import Foundation
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`JSONFileStore` `AtomicFileWriter`). Invalid/corrupt
/// JSON falls back to defaults. Saving posts `settingsDidChange` so #20 can
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
@@ -14,19 +14,18 @@ public final class SettingsStore: Sendable {
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
private let store: JSONFileStore<AppSettings>
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
}
public func load() -> AppSettings {
(try? store.load()) ?? .default
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
}
/// Validates before persisting throws `SettingsError` listing
@@ -36,7 +35,9 @@ public final class SettingsStore: Sendable {
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
try store.save(settings)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
@@ -74,24 +74,23 @@ public struct WizardState: Codable, Equatable, Sendable {
/// Atomic JSON persistence for `WizardState` (issue #4).
public final class WizardStateStore: Sendable {
public let fileURL: URL
private let store: JSONFileStore<WizardState>
public init(
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
) {
self.fileURL = fileURL
self.store = JSONFileStore(
fileURL: fileURL,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
}
public func load() -> WizardState {
(try? store.load()) ?? .default
guard let data = try? Data(contentsOf: fileURL),
let state = try? JSONDecoder().decode(WizardState.self, from: data)
else { return .default }
return state
}
public func save(_ state: WizardState) throws {
try store.save(state)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
}
}
@@ -1,61 +1,51 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+4 -7
View File
@@ -1,4 +1,3 @@
import AppKit
import SwiftUI
import ICCeryCore
@@ -10,12 +9,10 @@ struct AboutView: View {
var body: some View {
VStack(spacing: 20) {
if let icon = NSImage(named: NSImage.applicationIconName) {
Image(nsImage: icon)
.resizable()
.scaledToFit()
.frame(height: 64)
}
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 64)
Text("ICCery")
.font(.title)
+2 -7
View File
@@ -4,7 +4,6 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@Bindable var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -52,15 +51,11 @@ struct CalibrationView: View {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
.disabled(!model.canGenerate)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
.disabled(!model.canGenerate)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
+28 -22
View File
@@ -50,15 +50,14 @@ final class CalibrationViewModel {
return cwd.appendingPathComponent("\(calBasename).ti3")
}
private var identity: CalibrationIdentity {
CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
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 calBasename: String { identity.calibrationBasename }
private var calOutputURL: URL? {
guard let cwd = wizard.effectiveWorkingDirectory else { return nil }
return cwd.appendingPathComponent("\(calBasename).cal")
@@ -69,12 +68,13 @@ final class CalibrationViewModel {
func generateTarget() {
guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return }
// Snapshot the original (pre-CAL_) basename before changing the live one.
let identity = CalibrationIdentity.parse(
liveBasename: wizard.basename,
persistedOriginal: wizard.calibrationOriginalBasename
)
wizard.calibrationOriginalBasename = identity.originalBasename
wizard.basename = identity.calibrationBasename
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)"
wizard.sessionMode = .calibration
isGenerating = true
@@ -87,17 +87,20 @@ final class CalibrationViewModel {
whitePatches: whitePatches,
includeNeutralEmphasis: includeNeutralEmphasis,
inkLimit: inkLimitValue,
basename: identity.originalBasename,
basename: original,
workingDirectory: cwd
)
Task { @MainActor in
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isGenerating = false }
do {
_ = try await self.environment.runner.runCalibrationTargen(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
_ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.wizard.refreshGating()
self.wizard.showNotice("Calibration target generated.")
self.wizard.go(to: .layOutPrint)
@@ -157,13 +160,16 @@ final class CalibrationViewModel {
channelLimits: []
)
Task { @MainActor in
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isComputing = false }
do {
let url = try await self.environment.runner.runPrintcal(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.calibrationLog.append(contentsOf: batch)
})
let url = try await self.environment.runner.runPrintcal(config: config) { batch in
Task { @MainActor [weak self] in
self?.calibrationLog.append(contentsOf: batch)
}
}
self.computedCalURL = url
self.profile.calibrationFile = url.path
self.profile.applyCalibration = self.applyToProfile
+20 -20
View File
@@ -29,8 +29,13 @@ final class FileDialogService {
/// `selectTargetFile` **save** panel for the new `.ti1` target.
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
save(named: "target.ti1", extensions: ["ti1"], startingAt: start,
message: "Choose the .ti1 target file to create")
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)
}
/// `selectExistingTarget` open `.ti1`/`.ti2` (docs/06 §Resume, #140).
@@ -61,7 +66,12 @@ final class FileDialogService {
/// `selectCsvSavePath` verification-history CSV export.
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
save(named: "verification-history.csv", extensions: ["csv"], startingAt: start)
let panel = NSSavePanel()
panel.nameFieldStringValue = "verification-history.csv"
panel.allowedContentTypes = utTypes(["csv"])
panel.allowsOtherFileTypes = false
panel.directoryURL = start
return run(panel)
}
/// `selectCalFile` `.cal` calibration curves.
@@ -78,27 +88,17 @@ final class FileDialogService {
/// `btnExportActivePreset` save a `.json` preset file.
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
save(named: "\(name).json", extensions: ["json"], startingAt: start,
message: "Export this preset as JSON")
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)
}
// MARK: - Internals (private not a shared public picker API)
private func save(
named: String,
extensions: [String],
startingAt start: URL?,
message: String? = nil
) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = named
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = false
panel.directoryURL = start
if let message { panel.message = message }
return run(panel)
}
private func open(
extensions: [String],
startingAt start: URL?,
@@ -70,7 +70,8 @@ final class MeasurementWorkflowViewModel {
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: Notice?
var finishNotice: String?
var finishNoticeIsError = false
var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
@@ -399,6 +400,7 @@ 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 }
@@ -418,8 +420,10 @@ final class MeasurementWorkflowViewModel {
)
canonical = try await self.environment.runner.runAverage(
config: config,
onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.chartreadLog.append(contentsOf: batch)
onLogBatch: { [weak self] batch in
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
)
}
@@ -428,11 +432,7 @@ final class MeasurementWorkflowViewModel {
if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile)
} else {
self.finishNotice = Notice(
kind: .info,
text: "Finished: \(canonical.lastPathComponent) ready.",
autoHideAfter: nil
)
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
}
} catch {
// Fallback to pass 1 promotion if averaging failed.
@@ -445,24 +445,15 @@ final class MeasurementWorkflowViewModel {
)
self.discoverPassSnapshots()
self.wizard.refreshGating()
self.finishNotice = Notice(
kind: .error,
text: "Averaging failed — promoted first pass.",
autoHideAfter: nil
)
self.finishNotice = "Averaging failed — promoted first pass."
self.finishNoticeIsError = true
} catch {
self.finishNotice = Notice(
kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
} else {
self.finishNotice = Notice(
kind: .error,
text: "Finish failed: \(error.localizedDescription)",
autoHideAfter: nil
)
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
}
self.isFinishing = false
@@ -1,181 +0,0 @@
import Foundation
import Observation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor
@Observable
final class PrintSessionViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
var printers: [Printer] = []
var selectedPrinter = ""
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice?
var isPrinting = false
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
}
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
} catch {
printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
}
}
}
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = Notice(
kind: .info,
text: "Printer properties dialog cancelled.",
autoHideAfter: nil
)
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(kind: .error, text: error.localizedDescription)
}
}
}
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index, pageSize: pageSize)
printed += 1
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed on \(page.page.filename): "
+ error.localizedDescription
)
isPrinting = false
return
}
}
printNotice = Notice(
kind: .info,
text: "Sent \(printed) page(s) to \(selectedPrinter).",
autoHideAfter: nil
)
isPrinting = false
}
printTask = task
}
func printPage(_ page: GalleryPage, pageSize: PageSize) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index, pageSize: pageSize)
printNotice = Notice(
kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed: \(error.localizedDescription)"
)
}
isPrinting = false
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
wizard.printerName = selectedPrinter
}
}
-27
View File
@@ -1,27 +0,0 @@
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)
}
}
-26
View File
@@ -1,26 +0,0 @@
import Foundation
/// Shared hop for coalesced Argyll log batches (issue #80).
/// The runner invokes the sink off the main actor; this is the single hop back.
enum ProcessRunSupport {
static func logSink(
_ apply: @escaping @MainActor @Sendable ([String]) -> Void
) -> @Sendable ([String]) -> Void {
{ batch in
Task { @MainActor in apply(batch) }
}
}
@MainActor
static func runLogged<T>(
setRunning: (Bool) -> Void,
resetLog: () -> Void,
onLog: @escaping @MainActor @Sendable ([String]) -> Void,
work: (@escaping @Sendable ([String]) -> Void) async throws -> T
) async throws -> T {
setRunning(true)
resetLog()
defer { setRunning(false) }
return try await work(logSink(onLog))
}
}
+29 -25
View File
@@ -123,15 +123,11 @@ final class ProfileWorkflowViewModel {
func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return }
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 {
algorithm = preset.colprofAlgorithm ?? "l"
quality = preset.colprofQuality ?? "m"
intent = preset.colprofIntent ?? ""
if let fwa = preset.colprofFwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
@@ -142,10 +138,11 @@ final class ProfileWorkflowViewModel {
fwaCustomPath = fwa
}
}
illuminant = config.illuminant ?? ""
observer = config.observer ?? ""
inputViewingCond = config.inputViewingCond ?? ""
outputViewingCond = config.outputViewingCond ?? ""
illuminant = preset.colprofIlluminant ?? ""
observer = preset.colprofObserver ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
}
/// Stage 4 form values for saving into a custom preset.
@@ -208,13 +205,16 @@ final class ProfileWorkflowViewModel {
defer { self.isColprofRunning = false }
do {
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))
let url = try await runner.runColprof(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
}
}
})
}
var finalProfileURL = url
@@ -231,9 +231,11 @@ 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, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
let gamURL = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.createdGamutURL = gamURL
self.colprofLog.append("Gamut mesh extracted: \(gamURL.lastPathComponent)")
} catch {
@@ -325,9 +327,11 @@ final class ProfileWorkflowViewModel {
defer { self.isProfcheckRunning = false }
do {
let report = try await runner.runProfcheck(config: config, onLogBatch: ProcessRunSupport.logSink { [weak self] batch in
self?.colprofLog.append(contentsOf: batch)
})
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
+5 -13
View File
@@ -29,7 +29,7 @@ struct RootView: View {
if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice)
}
WizardStageContent(model: model, workflow: workflow)
stageContent
}
}
.frame(minWidth: 1100, minHeight: 700)
@@ -61,16 +61,8 @@ struct RootView: View {
}
}
}
/// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View {
@Bindable var model: WizardViewModel
var workflow: TargetWorkflowViewModel
var body: some View {
@ViewBuilder
private var stageContent: some View {
switch model.stage {
case .generate:
Stage1View(workflow: workflow)
@@ -83,9 +75,9 @@ private struct WizardStageContent: View {
case .verifyInstall:
Stage5View(model: workflow.profile)
case .calibrate:
CalibrationView(model: workflow.calibration, wizard: workflow.wizard)
CalibrationView(model: workflow.calibration)
@unknown default:
Stage1View(workflow: workflow)
StagePlaceholderView(stage: model.stage)
}
}
}
+14 -7
View File
@@ -238,12 +238,19 @@ struct Stage1View: View {
}
private var logSection: some View {
ProcessLogView(
lines: workflow.targenLog,
minHeight: 120,
maxHeight: 200,
containerId: "targenLogContainer",
logId: "targenLog"
)
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")
}
}
+50 -47
View File
@@ -171,13 +171,20 @@ struct Stage2View: View {
}
private var logSection: some View {
ProcessLogView(
lines: workflow.printtargLog,
minHeight: 100,
maxHeight: 180,
containerId: "printtargLogContainer",
logId: "printtargLog"
)
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")
}
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@@ -212,18 +219,18 @@ struct Stage2View: View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Text("Print").font(.headline).foregroundStyle(Theme.text)
if let notice = workflow.print.printNotice {
Image(systemName: notice.kind == .error
if let notice = workflow.printNotice {
Image(systemName: workflow.printNoticeIsError
? "xmark.circle.fill" : "info.circle.fill")
.foregroundStyle(notice.kind == .error
.foregroundStyle(workflow.printNoticeIsError
? .red : .blue)
.accessibilityIdentifier("printNotificationIcon")
Text(notice.text)
Text(notice)
.font(.caption)
.foregroundStyle(notice.kind == .error
.foregroundStyle(workflow.printNoticeIsError
? .red : .secondary)
.accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice.text)
.accessibilityValue(notice)
}
Spacer()
}
@@ -231,21 +238,21 @@ struct Stage2View: View {
// Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) {
Picker("Printer", selection: $workflow.print.selectedPrinter) {
ForEach(workflow.print.printers, id: \.name) { printer in
Picker("Printer", selection: $workflow.selectedPrinter) {
ForEach(workflow.printers, id: \.name) { printer in
Text(printer.displayName ?? printer.name)
.tag(printer.name)
}
}
.frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _, _ in
workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
.onChange(of: workflow.selectedPrinter) { _, _ in
workflow.selectedTray = nil
workflow.selectedMediaType = nil
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
}
if let selected = workflow.print.printers
.first(where: { $0.name == workflow.print.selectedPrinter }) {
if let selected = workflow.printers
.first(where: { $0.name == workflow.selectedPrinter }) {
Text(selected.status.rawValue)
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 3)
@@ -253,33 +260,33 @@ struct Stage2View: View {
.clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge")
}
Button(action: workflow.print.refreshPrinters) {
Button(action: workflow.refreshPrinters) {
Image(systemName: "arrow.clockwise")
}
.help("Refresh printer list")
.accessibilityIdentifier("btnRefreshPrinters")
Button(action: workflow.print.openPrinterPreferences) {
Button(action: workflow.openPrinterPreferences) {
Image(systemName: "gearshape")
}
.help("Printer properties — bound NSPrintPanel")
.disabled(workflow.print.selectedPrinter.isEmpty)
.disabled(workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrinterProperties")
}
// Tray / media / orientation from queue capabilities.
HStack(spacing: 14) {
if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) {
ForEach(workflow.print.printerCaps.trays, id: \.id) {
if !workflow.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.selectedTray) {
ForEach(workflow.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerTraySelect")
}
if !workflow.print.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.print.selectedMediaType) {
ForEach(workflow.print.printerCaps.mediaTypes, id: \.id) {
if !workflow.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.selectedMediaType) {
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
@@ -289,31 +296,27 @@ struct Stage2View: View {
.accessibilityIdentifier("printerMediaTypeSelect")
}
HStack(spacing: 0) {
Button("Portrait") { workflow.print.printOrientation = "portrait" }
Button("Portrait") { workflow.printOrientation = "portrait" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.print.printOrientation = "landscape" }
Button("Landscape") { workflow.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
}
Spacer()
}
HStack(spacing: 8) {
Button(action: {
if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
Button(action: workflow.printAllPages) {
Label(workflow.isPrinting ? "Printing…" : "Print All",
systemImage: "printer")
}
.controlSize(.large)
.disabled(workflow.print.isPrinting
.disabled(workflow.isPrinting
|| workflow.printtargResult == nil
|| workflow.print.selectedPrinter.isEmpty)
|| workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll")
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
@@ -330,8 +333,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.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters()
if workflow.printers.isEmpty, workflow.printtargResult != nil {
workflow.refreshPrinters()
}
}
}
@@ -361,9 +364,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.print.printPage(page, pageSize: workflow.pageSize) }
.disabled(workflow.print.isPrinting
|| workflow.print.selectedPrinter.isEmpty)
Button("Print") { workflow.printPage(page) }
.disabled(workflow.isPrinting
|| workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
}
.padding(8)
+2 -2
View File
@@ -371,9 +371,9 @@ struct Stage3View: View {
}
if let notice = model.finishNotice {
Text(notice.text)
Text(notice)
.font(.caption)
.foregroundStyle(notice.kind == .error ? .red : .green)
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
}
}
.padding(16)
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (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)
}
}
+274 -68
View File
@@ -89,6 +89,30 @@ final class TargetWorkflowViewModel {
/// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false
// MARK: - Print panel (issue 17)
/// CUPS destinations from `lpstat` (#printerSelect).
var printers: [Printer] = []
/// Selected queue name.
var selectedPrinter = ""
/// Capabilities of the selected queue (#printerTraySelect /
/// #printerMediaTypeSelect / PageSize source).
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
var printOrientation = "portrait"
/// Per-queue captured `key=value` strings from Preferences replayed
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
var capturedCupsOptions: [String: String] = [:]
/// In-panel notice (#printNotification) cancel info, not error.
var printNotice: String?
var printNoticeIsError = false
var isPrinting = false
/// Strong reference to the active print task so the unstructured
/// `Task` is not dropped before it resumes.
private var printTask: Task<Void, Never>?
// MARK: - Presets
var presets: [ProfilingPreset] = []
@@ -106,8 +130,6 @@ 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
@@ -120,7 +142,6 @@ final class TargetWorkflowViewModel {
wizard: wizard,
environment: environment
)
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
@@ -212,12 +233,10 @@ final class TargetWorkflowViewModel {
let runner = environment.runner
Task { @MainActor in
do {
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)
let url = try await runner.runTargen(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.targenLog.append(contentsOf: batch)
}
}
wizard.setTarget(
basename: config.basename,
@@ -228,8 +247,8 @@ final class TargetWorkflowViewModel {
} catch {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
targenRunning = false
}
targenRunning = false
}
}
@@ -265,6 +284,8 @@ 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)
}
@@ -345,21 +366,22 @@ final class TargetWorkflowViewModel {
let runner = environment.runner
Task { @MainActor in
do {
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)
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.printtargLog.append(contentsOf: batch)
}
}
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
}
}
@@ -369,6 +391,160 @@ 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() {
@@ -378,57 +554,55 @@ 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) {
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)
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
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)
@@ -440,11 +614,39 @@ final class TargetWorkflowViewModel {
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
targen: buildTargenConfig(),
printtarg: buildPrinttargConfig(),
colprof: profile.buildColprofConfig(),
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,
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
applyCalibration: profile.applyCalibration ? true : nil
applyCalibration: profile.applyCalibration ? true : nil,
colprofAlgorithm: profile.algorithm,
colprofQuality: profile.quality,
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
colprofFwa: profile.fwaValue,
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
)
do {
try environment.presetStore.saveCustom(preset)
@@ -508,6 +710,10 @@ final class TargetWorkflowViewModel {
}
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
PageSize.parseCustom(raw)
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)
}
}
+4 -8
View File
@@ -67,12 +67,8 @@ 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 CalibrationIdentity.isCalibration(basename), !calibrationOriginalBasename.isEmpty {
let identity = CalibrationIdentity.parse(
liveBasename: basename,
persistedOriginal: calibrationOriginalBasename
)
basename = identity.originalBasename
if basename.hasPrefix("CAL_"), !calibrationOriginalBasename.isEmpty {
basename = calibrationOriginalBasename
calibrationOriginalBasename = ""
sessionMode = .profile
stage = .generate
@@ -132,7 +128,7 @@ final class WizardViewModel {
/// refused and the original basename is restored (#29).
func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return }
if CalibrationIdentity.isCalibration(basename) {
if basename.hasPrefix("CAL_") {
guard !calibrationOriginalBasename.isEmpty else {
showNotice(
"Cannot leave calibration — the original target name is missing.",
@@ -156,7 +152,7 @@ final class WizardViewModel {
}
func enterCalibration() {
if !basename.isEmpty, !CalibrationIdentity.isCalibration(basename), calibrationOriginalBasename.isEmpty {
if !basename.isEmpty, !basename.hasPrefix("CAL_"), calibrationOriginalBasename.isEmpty {
calibrationOriginalBasename = basename
}
sessionMode = .calibration
@@ -122,29 +122,3 @@ 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)
}
}
@@ -1,92 +0,0 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("JSONFileStore")
struct JSONFileStoreTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
@Test("Corrupt file with replaceWithDefault returns default")
func corruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
#expect(try store.load() == .default)
#expect(FileManager.default.fileExists(atPath: url.path))
}
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] }
)
#expect(throws: DecodingError.self) {
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
}
@Test("Pretty sorted keys")
func prettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\""))
}
}
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
}
@Test("persisted original wins")
func persistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
}
@Test("empty live does not invent a name")
func emptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
}
}
-58
View File
@@ -230,61 +230,3 @@ 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)
}
}
-78
View File
@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 660 400" width="660" height="400">
<defs>
<!-- Logo Clip Path -->
<clipPath id="coneClip">
<path d="M 50 82 L 110 82 L 80 142 Z" />
</clipPath>
<!-- Logo Text Gradient -->
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00AEEF" />
<stop offset="100%" stop-color="#0066CC" />
</linearGradient>
<!-- Arrow Drop Shadow Filter -->
<filter id="arrowShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Solid Background (matches app --bg-color: #1e1e1e) -->
<rect width="660" height="400" fill="#1e1e1e" />
<!-- Top-Left Branding Logo & Text (standard header height) -->
<g transform="translate(24, 18) scale(0.44)">
<!-- Drop Shadow -->
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
<!-- Ice Cream Cone (Waffle) -->
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
<line x1="40" y1="80" x2="120" y2="160" />
<line x1="55" y1="80" x2="135" y2="160" />
<line x1="70" y1="80" x2="150" y2="160" />
<line x1="85" y1="80" x2="165" y2="160" />
<line x1="120" y1="80" x2="40" y2="160" />
<line x1="105" y1="80" x2="25" y2="160" />
<line x1="90" y1="80" x2="10" y2="160" />
<line x1="75" y1="80" x2="-5" y2="160" />
</g>
<!-- CMYK Scoops (C, M, Y) -->
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
<!-- Black (Key) Cherry -->
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
<!-- Highlights on scoops for 3D effect -->
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
<!-- Text: ICCery -->
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
</g>
<!-- Center White Arrow (Pointing right between App Icon and Applications folder) -->
<g filter="url(#arrowShadow)">
<path d="M 285 214
L 342 214
L 342 201
Q 342 198 345 199.5
L 380 217.5
Q 383 219 383 220
Q 383 221 380 222.5
L 345 240.5
Q 342 242 342 239
L 342 226
L 285 226
Q 282 226 282 223
L 282 217
Q 282 214 285 214 Z"
fill="#ffffff" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

+3 -3
View File
@@ -32,7 +32,7 @@ if background and not os.path.exists(background):
icon = None
# Window size is enough for the app icon and the Applications alias.
window_rect = ((100, 100), (660, 400))
window_rect = ((100, 100), (640, 480))
# Use icon view without extra chrome.
default_view = 'icon-view'
@@ -45,8 +45,8 @@ sidebar_width = 180
# Position the .app on the left and the Applications alias on the right.
icon_locations = {
'ICCery.app': (180, 220),
'Applications': (480, 220),
'ICCery.app': (140, 240),
'Applications': (500, 240),
}
# Symlink to /Applications for drag-and-drop install.
+4 -5
View File
@@ -132,11 +132,8 @@ dmgbuild -s scripts/dmgbuild-settings.py "$VOLUME_NAME" "$DMG"
echo "DMG: $PWD/$DMG"
# Notarization requires a Developer ID signature. If the app was ad-hoc
# signed or any notarization secret is missing, skip silently — the DMG is
# still usable for local/testing installs.
if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ] && \
[ -n "${NOTARIZE_APPLE_ID:-}" ] && \
# Optional notarization/stapling when credentials are present.
if [ -n "${NOTARIZE_APPLE_ID:-}" ] && \
[ -n "${NOTARIZE_PASSWORD:-}" ] && \
[ -n "${APPLE_TEAM_ID:-}" ]; then
echo "==> Submitting $DMG for notarization"
@@ -147,4 +144,6 @@ if [ -n "${CODESIGN_IDENTITY:-}" ] && [ "$CODESIGN_IDENTITY" != "-" ] && \
--wait
xcrun stapler staple "$DMG"
echo "==> Stapled $DMG"
else
echo "==> Notarization credentials not set; skipping"
fi