Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a73c6c0b97 | ||
|
|
563f0e5d4a | ||
|
|
cd4665e7a9 | ||
|
|
153b6194a3 | ||
|
|
f78da50a59 | ||
|
|
629a1fce1d | ||
|
|
460b0a1ffa | ||
|
|
ef56cdd7d4 | ||
|
|
61c6d62ee2 | ||
|
|
f769cf7fec | ||
|
|
da53feec7f | ||
|
|
64393b7591 | ||
|
|
cc184bfff3 | ||
|
|
bb6ca957ba | ||
|
|
ad6f8247d2 | ||
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 | ||
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
14f521a65e | ||
|
|
4281d07754 | ||
|
|
71172d751a | ||
|
|
73db1c8c25 | ||
|
|
7c1303ac11 | ||
|
|
797d30b023 | ||
|
|
65ba6dc61a | ||
|
|
c0f5fb8c28 | ||
|
|
dc62f5c016 | ||
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc |
@@ -0,0 +1,800 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `ArgyllRunner` executions.
|
||||||
|
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||||
|
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 applycalFailed(String)
|
||||||
|
case iccgamutFailed(String)
|
||||||
|
case profcheckFailed(String)
|
||||||
|
case profcheckUnparseable
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .processFailed(let code, _):
|
||||||
|
return "Process exited with code \(code)"
|
||||||
|
case .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 .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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful `printtarg` run: the `.ti2` artefact plus the
|
||||||
|
/// validated manifest with per-page PNG previews already decoded.
|
||||||
|
public struct PrinttargResult: Sendable, Equatable {
|
||||||
|
public let ti2URL: URL
|
||||||
|
public let manifest: PrinttargManifest
|
||||||
|
public let pages: [GalleryPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service driving Argyll subprocesses off the main actor
|
||||||
|
/// (docs/03, docs/08, docs/09).
|
||||||
|
///
|
||||||
|
/// - Subscribes to the event bus *before* spawning so no stdout or exit
|
||||||
|
/// is ever lost (subscription is synchronous in `ProcessManager`).
|
||||||
|
/// - Accumulates stdout/stderr without touching `@MainActor`; the
|
||||||
|
/// optional `onLogBatch` callback receives coalesced chunks (20 lines
|
||||||
|
/// or ~100 ms), never one call per line.
|
||||||
|
/// - Exit code 0 is necessary but not sufficient: the expected artefact
|
||||||
|
/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a
|
||||||
|
/// valid `-u` manifest.
|
||||||
|
public struct ArgyllRunner: Sendable {
|
||||||
|
public let processManager: ProcessManager
|
||||||
|
public let binaryResolver: BinaryResolver
|
||||||
|
|
||||||
|
public init(
|
||||||
|
processManager: ProcessManager = .shared,
|
||||||
|
binaryResolver: BinaryResolver = BinaryResolver()
|
||||||
|
) {
|
||||||
|
self.processManager = processManager
|
||||||
|
self.binaryResolver = binaryResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - targen (Stage 1)
|
||||||
|
|
||||||
|
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||||
|
/// upon completion. Returns the `.ti1` URL.
|
||||||
|
public func runTargen(
|
||||||
|
config: TargenConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try TargenArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("targen")
|
||||||
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||||
|
}
|
||||||
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
|
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||||
|
}
|
||||||
|
return ti1URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - printtarg (Stage 2)
|
||||||
|
|
||||||
|
/// Runs `printtarg` streaming, then parses the `-u` manifest from
|
||||||
|
/// the complete accumulated stdout and loads each page's PNG
|
||||||
|
/// preview via `TiffPreview` (host-side, never raw TIFF to the UI).
|
||||||
|
public func runPrinttarg(
|
||||||
|
config: PrinttargConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> PrinttargResult {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try PrinttargArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("printtarg")
|
||||||
|
let processId = ProcessID.printtarg(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||||
|
}
|
||||||
|
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
||||||
|
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: PrinttargManifest
|
||||||
|
do {
|
||||||
|
manifest = try PrinttargManifestExtractor.manifest(from: run.stdout)
|
||||||
|
} catch {
|
||||||
|
throw ArgyllRunnerError.malformedManifest(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in
|
||||||
|
let fileURL = cwd.appendingPathComponent(page.filename)
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: nil, previewError: "File not found"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let png = TiffPreview.previewPNG(tiff: fileURL) {
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: png, previewError: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: nil, previewError: "Could not decode TIFF"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared collection
|
||||||
|
|
||||||
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
|
private func ensureNotRunning(id: String) async {
|
||||||
|
guard await processManager.isRunning(id) else { return }
|
||||||
|
await processManager.kill(id: id)
|
||||||
|
var attempts = 0
|
||||||
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
attempts += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CollectedRun {
|
||||||
|
var exitCode: Int32?
|
||||||
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
|
var lines: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains the event stream until this child's `exit` event.
|
||||||
|
/// stdout is accumulated both per-line (logs) and verbatim (for
|
||||||
|
/// the manifest parse — the pretty JSON needs its newlines).
|
||||||
|
///
|
||||||
|
/// When `flushPartialLines` is `true`, a background `Task` flushes
|
||||||
|
/// unterminated output every 500 ms so tools like `colprof` that
|
||||||
|
/// print dots without newlines still produce log batches.
|
||||||
|
private func collect(
|
||||||
|
id processId: String,
|
||||||
|
events: AsyncStream<ProcessEvent>,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)?,
|
||||||
|
flushPartialLines: Bool = false
|
||||||
|
) async -> CollectedRun {
|
||||||
|
var lines: [String] = []
|
||||||
|
var stdout = ""
|
||||||
|
var stderr = ""
|
||||||
|
var pendingBatch: [String] = []
|
||||||
|
var exitCode: Int32?
|
||||||
|
var lastFlush = Date()
|
||||||
|
|
||||||
|
func flush(_ batch: inout [String]) {
|
||||||
|
guard !batch.isEmpty else { return }
|
||||||
|
let out = batch
|
||||||
|
batch.removeAll(keepingCapacity: true)
|
||||||
|
onLogBatch?(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dotFlushTask: Task<Void, Never>?
|
||||||
|
if flushPartialLines {
|
||||||
|
dotFlushTask = Task { [processManager] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await processManager.flushPartialLine(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
lines.append(line)
|
||||||
|
stdout += line + "\n"
|
||||||
|
pendingBatch.append(line)
|
||||||
|
case .stderr(_, let line):
|
||||||
|
lines.append(line)
|
||||||
|
stderr += line + "\n"
|
||||||
|
pendingBatch.append(line)
|
||||||
|
case .error(_, let message):
|
||||||
|
lines.append("Error: \(message)")
|
||||||
|
pendingBatch.append("Error: \(message)")
|
||||||
|
case .jsonRow:
|
||||||
|
// Only chartread emits these; targen/printtarg never do.
|
||||||
|
break
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingBatch.count >= 20
|
||||||
|
|| Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flush(&pendingBatch)
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
if exitCode != nil {
|
||||||
|
flush(&pendingBatch)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dotFlushTask?.cancel()
|
||||||
|
if let dotFlushTask {
|
||||||
|
_ = await dotFlushTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - instlist (Stage 3 detection)
|
||||||
|
|
||||||
|
/// Runs `instlist` and returns the detected devices.
|
||||||
|
///
|
||||||
|
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
|
||||||
|
/// fallback constrained to known instrument tokens is used.
|
||||||
|
public func detectInstruments() async throws -> [InstrumentDevice] {
|
||||||
|
let binaryURL = binaryResolver.resolve("instlist")
|
||||||
|
let processId = ProcessID.instlist
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: [],
|
||||||
|
workingDirectory: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
var accumulator = JSONAccumulator()
|
||||||
|
var stdout = ""
|
||||||
|
var stderr: [String] = []
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
stdout += line + "\n"
|
||||||
|
_ = accumulator.feed(line: line)
|
||||||
|
case .stderr(_, let line):
|
||||||
|
stderr.append(line)
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if exitCode != nil { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
|
||||||
|
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let code = exitCode, code != 0, stderr.isEmpty == false {
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: try to parse the raw stdout as a text document.
|
||||||
|
if let devices = try? InstrumentParser.parse(stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - average (Stage 3 multi-pass finish)
|
||||||
|
|
||||||
|
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
|
||||||
|
public func runAverage(
|
||||||
|
config: AverageConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try AverageArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("average")
|
||||||
|
let processId = ProcessID.average(config.basename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||||
|
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - colprof (Stage 4)
|
||||||
|
|
||||||
|
/// Runs `colprof` streaming, collecting logs and classifying progress
|
||||||
|
/// until the profile is written.
|
||||||
|
public func runColprof(
|
||||||
|
config: ColprofConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("colprof")
|
||||||
|
let processId = ProcessID.colprof(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(
|
||||||
|
id: processId,
|
||||||
|
events: events,
|
||||||
|
onLogBatch: onLogBatch,
|
||||||
|
flushPartialLines: true
|
||||||
|
)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.colprofFailed(
|
||||||
|
"colprof exited with code \(run.exitCode ?? -1)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||||
|
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||||
|
guard let profileURL = ArtefactProbe.resolveProfile(
|
||||||
|
basename: cleanBasename,
|
||||||
|
cwd: cwd
|
||||||
|
) else {
|
||||||
|
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
|
||||||
|
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
|
||||||
|
}
|
||||||
|
return profileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - applycal (post-colprof calibration curve)
|
||||||
|
|
||||||
|
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
|
||||||
|
///
|
||||||
|
/// Runs `applycal` captured and performs an in-place replace via
|
||||||
|
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
|
||||||
|
/// file is removed and the original is left untouched. The UI must
|
||||||
|
/// never request `unapply` (#52).
|
||||||
|
public func runApplycal(
|
||||||
|
config: ApplycalConfig
|
||||||
|
) async throws -> URL {
|
||||||
|
assert(!config.unapply, "runApplycal does not support unapply")
|
||||||
|
|
||||||
|
let inputURL = config.inputProfileURL
|
||||||
|
let cwd = inputURL.deletingLastPathComponent()
|
||||||
|
let binaryURL = binaryResolver.resolve("applycal")
|
||||||
|
let processId = ProcessID.applycal(inputURL.lastPathComponent)
|
||||||
|
|
||||||
|
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
|
||||||
|
let fm = FileManager.default
|
||||||
|
|
||||||
|
// Remove any stale tmp from a previous crash.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
|
||||||
|
let outputConfig = ApplycalConfig(
|
||||||
|
calibrationPath: config.calibrationPath,
|
||||||
|
inputProfileURL: inputURL,
|
||||||
|
outputProfileURL: tmpURL,
|
||||||
|
unapply: false
|
||||||
|
)
|
||||||
|
let outputArgs = try ApplycalArgs.build(config: outputConfig)
|
||||||
|
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: outputArgs,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
guard result.exitCode == 0, !Task.isCancelled else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
if Task.isCancelled {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
result.stderr.isEmpty
|
||||||
|
? "applycal exited with code \(result.exitCode)"
|
||||||
|
: result.stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
"applycal did not create temp profile"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
"calibrated profile is too small (\(size) bytes)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if fm.fileExists(atPath: inputURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: inputURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iccgamut (post-colprof gamut mesh)
|
||||||
|
|
||||||
|
/// Extracts a `.gam` mesh from the finished profile.
|
||||||
|
public func runIccgamut(
|
||||||
|
config: IccgamutConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let profileURL = config.profileURL
|
||||||
|
let cwd = profileURL.deletingLastPathComponent()
|
||||||
|
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||||
|
let args = try IccgamutArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("iccgamut")
|
||||||
|
let processId = ProcessID.iccgamut(stem: stem)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.iccgamutFailed(
|
||||||
|
"iccgamut exited with code \(run.exitCode ?? -1)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||||
|
guard FileManager.default.fileExists(atPath: gamURL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(gamURL.path)
|
||||||
|
}
|
||||||
|
return gamURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - profcheck (Stage 5 verification)
|
||||||
|
|
||||||
|
/// Verifies a profile against the canonical `.ti3`.
|
||||||
|
public func runProfcheck(
|
||||||
|
config: ProfcheckConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> ProfcheckReport {
|
||||||
|
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
|
||||||
|
let iccURL = Self.resolveProfileForVerification(config.iccURL)
|
||||||
|
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||||
|
|
||||||
|
let args = try ProfcheckArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("profcheck")
|
||||||
|
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.profcheckFailed(
|
||||||
|
run.stderr.isEmpty
|
||||||
|
? "profcheck exited with code \(run.exitCode ?? -1)"
|
||||||
|
: run.stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let report = ProfcheckParser.parse(output)
|
||||||
|
guard report.isValid else {
|
||||||
|
throw ArgyllRunnerError.profcheckUnparseable
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveProfileForVerification(_ url: URL) -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: url.path) { return url }
|
||||||
|
let alt = url.pathExtension.lowercased() == "icc"
|
||||||
|
? url.deletingPathExtension().appendingPathExtension("icm")
|
||||||
|
: url.deletingPathExtension().appendingPathExtension("icc")
|
||||||
|
return fm.fileExists(atPath: alt.path) ? alt : url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - chartread (Stage 3 interactive)
|
||||||
|
|
||||||
|
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
|
||||||
|
/// are all handled here. Use `sendChartreadInput` to drive the child and
|
||||||
|
/// `cancelChartread` to terminate it.
|
||||||
|
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
|
||||||
|
let cleanBasename: String
|
||||||
|
let cwd: URL
|
||||||
|
do {
|
||||||
|
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: [String]
|
||||||
|
do {
|
||||||
|
args = try ChartreadArgs.build(config: config)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let binaryURL = binaryResolver.resolve("chartread")
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// Register the XY parking hook before spawning.
|
||||||
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
|
if isXY {
|
||||||
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ChartreadState = .idle
|
||||||
|
var pendingLogs: [String] = []
|
||||||
|
var lastFlush = Date()
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
func flushLogs() {
|
||||||
|
guard !pendingLogs.isEmpty else { return }
|
||||||
|
let batch = pendingLogs
|
||||||
|
pendingLogs.removeAll(keepingCapacity: true)
|
||||||
|
continuation.yield(.log(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
let previous = state
|
||||||
|
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
|
||||||
|
state = classified.state
|
||||||
|
|
||||||
|
if classified.isRemoveSheetNotice {
|
||||||
|
continuation.yield(.removeSheetNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldPrompt =
|
||||||
|
classified.sheetNumber != nil
|
||||||
|
|| classified.alignmentPatch != nil
|
||||||
|
|| classified.requestedWarningKey != nil
|
||||||
|
|| classified.state != previous
|
||||||
|
|| classified.isTableContinuation
|
||||||
|
|
||||||
|
if shouldPrompt {
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow(_, let payload):
|
||||||
|
do {
|
||||||
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
|
||||||
|
state = row.isFinalRow ? .allStripsRead : state
|
||||||
|
continuation.yield(.row(row))
|
||||||
|
} catch {
|
||||||
|
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .error(_, let message):
|
||||||
|
pendingLogs.append("Error: \(message)")
|
||||||
|
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flushLogs()
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != nil {
|
||||||
|
flushLogs()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
if let code = exitCode, code == 0 {
|
||||||
|
if FileManager.default.fileExists(atPath: canonical.path) {
|
||||||
|
continuation.yield(.completed(canonical))
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an exact input sequence to the running `chartread` child.
|
||||||
|
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
try await processManager.sendStdin(id: processId, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `chartread` child.
|
||||||
|
///
|
||||||
|
/// The actual XY parking is handled by the pre-kill hook registered in
|
||||||
|
/// `runChartread`.
|
||||||
|
public func cancelChartread(basename: String, isXY: Bool = false) {
|
||||||
|
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
|
||||||
|
guard let cleanBasename else { return }
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events emitted by a running `chartread` session.
|
||||||
|
public enum ChartreadEvent: Sendable {
|
||||||
|
/// Classified prompt / state update.
|
||||||
|
case prompt(ChartreadClassifyResult)
|
||||||
|
/// A decoded `ROW_COLORS_JSON` row.
|
||||||
|
case row(ChartreadRow)
|
||||||
|
/// A batched log chunk (stdout + stderr lines).
|
||||||
|
case log([String])
|
||||||
|
/// Informational "remove last sheet" notice.
|
||||||
|
case removeSheetNotice
|
||||||
|
/// Process exited with the given code.
|
||||||
|
case exit(Int32)
|
||||||
|
/// Successful completion with the canonical `.ti3` URL.
|
||||||
|
case completed(URL)
|
||||||
|
/// Failure (non-zero exit, missing artefact, spawn/parse error).
|
||||||
|
case failed(ArgyllRunnerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
|
public enum ChartreadInput: Sendable {
|
||||||
|
case trigger // " \n"
|
||||||
|
case accept // "\n"
|
||||||
|
case done // "d\n"
|
||||||
|
case quit // "q\n"
|
||||||
|
case customKey(String)
|
||||||
|
|
||||||
|
public var bytes: Data {
|
||||||
|
switch self {
|
||||||
|
case .trigger:
|
||||||
|
return Data(" \n".utf8)
|
||||||
|
case .accept:
|
||||||
|
return Data("\n".utf8)
|
||||||
|
case .done:
|
||||||
|
return Data("d\n".utf8)
|
||||||
|
case .quit:
|
||||||
|
return Data("q\n".utf8)
|
||||||
|
case .customKey(let key):
|
||||||
|
return Data("\(key)\n".utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2).
|
||||||
|
public enum PrinttargArgError: LocalizedError, Equatable {
|
||||||
|
case invalidCustomPageDimension(Double)
|
||||||
|
case invalidDPI(Int)
|
||||||
|
case invalidSeed(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCustomPageDimension(let mm):
|
||||||
|
return "Custom page dimensions must be at least 50 mm, got: \(mm)"
|
||||||
|
case .invalidDPI(let dpi):
|
||||||
|
return "TIFF DPI must be between 72 and 600, got: \(dpi)"
|
||||||
|
case .invalidSeed(let seed):
|
||||||
|
return "Custom layout seed must be ≥ 1, got: \(seed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2).
|
||||||
|
///
|
||||||
|
/// Contract:
|
||||||
|
/// ```
|
||||||
|
/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
|
||||||
|
/// ```
|
||||||
|
public enum PrinttargArgs {
|
||||||
|
|
||||||
|
/// Builds the exact command-line arguments for `printtarg`.
|
||||||
|
///
|
||||||
|
/// Invariants:
|
||||||
|
/// - Always `-v -u` (the fork's `-u` emits the JSON page manifest).
|
||||||
|
/// - Default layout is deterministic `-R 1` (#163) — a missing seed
|
||||||
|
/// reshuffles patches on every re-run and desyncs print vs `.ti2`.
|
||||||
|
/// - `.raster` emits `-r` and supersedes any seed. This is NOT
|
||||||
|
/// targen's `-r` full-spread algorithm (docs/25).
|
||||||
|
/// - `-d` is the chart **label** string, not colour space.
|
||||||
|
/// - `-K`/`-I` are never emitted for `CAL_` basenames — the
|
||||||
|
/// calibration chart must not embed its own curves.
|
||||||
|
/// - Basename is the last positional argument.
|
||||||
|
public static func build(config: PrinttargConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = [
|
||||||
|
"-v", "-u",
|
||||||
|
"-i", config.instrument.rawValue,
|
||||||
|
"-p", try pageSizeValue(config),
|
||||||
|
]
|
||||||
|
|
||||||
|
switch config.layoutOrder {
|
||||||
|
case .deterministic:
|
||||||
|
args.append(contentsOf: ["-R", "1"])
|
||||||
|
case .customSeed:
|
||||||
|
guard config.customSeed >= 1 else {
|
||||||
|
throw PrinttargArgError.invalidSeed(config.customSeed)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-R", "\(config.customSeed)"])
|
||||||
|
case .raster:
|
||||||
|
args.append("-r")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!label.isEmpty {
|
||||||
|
args.append(contentsOf: ["-d", label])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (72...600).contains(config.dpi) else {
|
||||||
|
throw PrinttargArgError.invalidDPI(config.dpi)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
|
||||||
|
|
||||||
|
if !cleanBasename.hasPrefix("CAL_"),
|
||||||
|
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!cal.isEmpty {
|
||||||
|
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pageSizeValue(_ config: PrinttargConfig) throws -> String {
|
||||||
|
guard config.pageSize == .custom else { return config.pageSize.rawValue }
|
||||||
|
for dim in [config.customPageWidth, config.customPageHeight] {
|
||||||
|
guard dim >= 50 else {
|
||||||
|
throw PrinttargArgError.invalidCustomPageDimension(dim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats millimetres as an integer when exact, else decimal.
|
||||||
|
private static func formatMM(_ value: Double) -> String {
|
||||||
|
if value == value.rounded(), abs(value) < 1e15 {
|
||||||
|
return "\(Int(value))"
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Measurement instrument for `printtarg -i` chart geometry
|
||||||
|
/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes.
|
||||||
|
public enum PrintInstrument: String, Codable, Sendable, CaseIterable {
|
||||||
|
case i1
|
||||||
|
case p3
|
||||||
|
case cm = "CM"
|
||||||
|
case ss = "SS"
|
||||||
|
case dtp20 = "20"
|
||||||
|
case dtp22 = "22"
|
||||||
|
case dtp41 = "41"
|
||||||
|
case dtp51 = "51"
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .i1: return "X-Rite i1Pro / i1Pro 2"
|
||||||
|
case .p3: return "X-Rite i1Pro 3 / 3 Plus"
|
||||||
|
case .cm: return "ColorMunki"
|
||||||
|
case .ss: return "Specbos / Spectraval (XY table)"
|
||||||
|
case .dtp20: return "Gretag i1Display 2"
|
||||||
|
case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display"
|
||||||
|
case .dtp41: return "Datacolor Spyder 4/5"
|
||||||
|
case .dtp51: return "Spyder X"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm.
|
||||||
|
public enum PageSize: String, Codable, Sendable, CaseIterable {
|
||||||
|
case a4 = "A4"
|
||||||
|
case a4r = "A4R"
|
||||||
|
case a3 = "A3"
|
||||||
|
case a2 = "A2"
|
||||||
|
case letter = "Letter"
|
||||||
|
case letterR = "LetterR"
|
||||||
|
case legal = "Legal"
|
||||||
|
case fourBySix = "4x6"
|
||||||
|
case elevenBySeventeen = "11x17"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
public var isCustom: Bool { self == .custom }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit).
|
||||||
|
public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable {
|
||||||
|
case eight = 8
|
||||||
|
case sixteen = 16
|
||||||
|
|
||||||
|
public var flag: String {
|
||||||
|
switch self {
|
||||||
|
case .eight: return "-t"
|
||||||
|
case .sixteen: return "-T"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch layout order (docs/09 §Randomisation, #163).
|
||||||
|
/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and
|
||||||
|
/// supersedes any seed — never confuse with targen's `-r` algorithm.
|
||||||
|
public enum LayoutOrder: String, Codable, Sendable, CaseIterable {
|
||||||
|
case deterministic
|
||||||
|
case customSeed = "custom_seed"
|
||||||
|
case raster
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .deterministic: return "Deterministic (seed 1)"
|
||||||
|
case .customSeed: return "Custom seed"
|
||||||
|
case .raster: return "Raster order (no shuffle)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chart label metadata used to assemble the automatic `printtarg -d`
|
||||||
|
/// label. Any empty/missing component becomes `Unspecified` until real
|
||||||
|
/// printer metadata lands in M3.
|
||||||
|
public struct TargetLabelMetadata: Codable, Equatable, Sendable {
|
||||||
|
public var printer: String
|
||||||
|
public var inkSet: String
|
||||||
|
public var driverPaper: String
|
||||||
|
public var actualPaper: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
printer: String = "",
|
||||||
|
inkSet: String = "",
|
||||||
|
driverPaper: String = "",
|
||||||
|
actualPaper: String = ""
|
||||||
|
) {
|
||||||
|
self.printer = printer
|
||||||
|
self.inkSet = inkSet
|
||||||
|
self.driverPaper = driverPaper
|
||||||
|
self.actualPaper = actualPaper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the chart legend for `printtarg -d` (fork argyllcms#19,
|
||||||
|
/// ICCery #119). `-d` here is a **label string** — not targen's colour
|
||||||
|
/// space, not iccgamut's density (docs/25).
|
||||||
|
public enum PrinttargLabel {
|
||||||
|
|
||||||
|
public static let unspecified = "Unspecified"
|
||||||
|
|
||||||
|
/// `ICCery - {basename} - {printer} - {ink} - {driverPaper} -
|
||||||
|
/// {actualPaper} - DD/MM/YYYY HH:MM`
|
||||||
|
///
|
||||||
|
/// `date` is injected for deterministic tests; production passes
|
||||||
|
/// the current local time. A fixed POSIX locale keeps the format
|
||||||
|
/// stable regardless of user locale.
|
||||||
|
public static func automatic(
|
||||||
|
basename: String,
|
||||||
|
metadata: TargetLabelMetadata,
|
||||||
|
date: Date = Date(),
|
||||||
|
timeZone: TimeZone = .current
|
||||||
|
) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = timeZone
|
||||||
|
formatter.dateFormat = "dd/MM/yyyy HH:mm"
|
||||||
|
|
||||||
|
return [
|
||||||
|
"ICCery",
|
||||||
|
basename,
|
||||||
|
field(metadata.printer),
|
||||||
|
field(metadata.inkSet),
|
||||||
|
field(metadata.driverPaper),
|
||||||
|
field(metadata.actualPaper),
|
||||||
|
formatter.string(from: date),
|
||||||
|
].joined(separator: " - ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the label to emit: an explicit non-empty manual label
|
||||||
|
/// wins; otherwise the assembled automatic label.
|
||||||
|
public static func resolved(
|
||||||
|
customLabel: String?,
|
||||||
|
basename: String,
|
||||||
|
metadata: TargetLabelMetadata,
|
||||||
|
date: Date = Date(),
|
||||||
|
timeZone: TimeZone = .current
|
||||||
|
) -> String {
|
||||||
|
if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!label.isEmpty {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func field(_ value: String) -> String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? unspecified : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2).
|
||||||
|
public struct PrinttargConfig: Codable, Equatable, Sendable {
|
||||||
|
public var instrument: PrintInstrument
|
||||||
|
public var pageSize: PageSize
|
||||||
|
/// Custom page dimensions in millimetres; each must be ≥ 50 when
|
||||||
|
/// `pageSize == .custom`.
|
||||||
|
public var customPageWidth: Double
|
||||||
|
public var customPageHeight: Double
|
||||||
|
public var bitDepth: TiffBitDepth
|
||||||
|
public var dpi: Int
|
||||||
|
public var layoutOrder: LayoutOrder
|
||||||
|
/// Seed for `.customSeed` layout (`-R N`, N ≥ 1). Ignored for
|
||||||
|
/// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`).
|
||||||
|
public var customSeed: Int
|
||||||
|
/// Resolved `-d` label. Callers usually compute this via
|
||||||
|
/// `PrinttargLabel.resolved` so tests can inject the clock.
|
||||||
|
public var label: String?
|
||||||
|
/// `.cal` file applied to printed patches (`-K`), or embedded
|
||||||
|
/// without applying (`-I` when `calibrationEmbedOnly`). Never
|
||||||
|
/// emitted for `CAL_` basenames (Stage 0 protection).
|
||||||
|
public var calibrationFile: String?
|
||||||
|
public var calibrationEmbedOnly: Bool
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
instrument: PrintInstrument = .i1,
|
||||||
|
pageSize: PageSize = .a4,
|
||||||
|
customPageWidth: Double = 210,
|
||||||
|
customPageHeight: Double = 297,
|
||||||
|
bitDepth: TiffBitDepth = .eight,
|
||||||
|
dpi: Int = 300,
|
||||||
|
layoutOrder: LayoutOrder = .deterministic,
|
||||||
|
customSeed: Int = 1,
|
||||||
|
label: String? = nil,
|
||||||
|
calibrationFile: String? = nil,
|
||||||
|
calibrationEmbedOnly: Bool = false,
|
||||||
|
basename: String = "",
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.instrument = instrument
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.customPageWidth = customPageWidth
|
||||||
|
self.customPageHeight = customPageHeight
|
||||||
|
self.bitDepth = bitDepth
|
||||||
|
self.dpi = dpi
|
||||||
|
self.layoutOrder = layoutOrder
|
||||||
|
self.customSeed = customSeed
|
||||||
|
self.label = label
|
||||||
|
self.calibrationFile = calibrationFile
|
||||||
|
self.calibrationEmbedOnly = calibrationEmbedOnly
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One page of a `printtarg -u` manifest (docs/05 §2.3).
|
||||||
|
/// `patches` is a page-assigned count including TID/padding cells,
|
||||||
|
/// not strictly user patches.
|
||||||
|
public struct PrinttargPage: Codable, Equatable, Sendable {
|
||||||
|
public var filename: String
|
||||||
|
public var patches: Int
|
||||||
|
public var widthMm: Double
|
||||||
|
public var heightMm: Double
|
||||||
|
|
||||||
|
public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) {
|
||||||
|
self.filename = filename
|
||||||
|
self.patches = patches
|
||||||
|
self.widthMm = widthMm
|
||||||
|
self.heightMm = heightMm
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case filename, patches
|
||||||
|
case widthMm = "width_mm"
|
||||||
|
case heightMm = "height_mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final-only, pretty-printed, **unprefixed** JSON object emitted
|
||||||
|
/// by fork `printtarg -u` after all pages are written (docs/05 §2.3).
|
||||||
|
public struct PrinttargManifest: Codable, Equatable, Sendable {
|
||||||
|
public var event: String
|
||||||
|
public var pages: [PrinttargPage]
|
||||||
|
|
||||||
|
public init(event: String = "manifest", pages: [PrinttargPage]) {
|
||||||
|
self.event = event
|
||||||
|
self.pages = pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manifest page resolved against the working directory, with its
|
||||||
|
/// host-side PNG preview (#58 — TIFF is never fed to the UI directly).
|
||||||
|
public struct GalleryPage: Equatable, Sendable, Identifiable {
|
||||||
|
public var id: Int { index }
|
||||||
|
public let index: Int
|
||||||
|
public let page: PrinttargPage
|
||||||
|
public let fileURL: URL
|
||||||
|
public let previewPNG: Data?
|
||||||
|
public let previewError: String?
|
||||||
|
|
||||||
|
public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) {
|
||||||
|
self.index = index
|
||||||
|
self.page = page
|
||||||
|
self.fileURL = fileURL
|
||||||
|
self.previewPNG = previewPNG
|
||||||
|
self.previewError = previewError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ManifestError: LocalizedError, Equatable {
|
||||||
|
case noJSONDocument
|
||||||
|
case wrongEvent(String)
|
||||||
|
case decodeFailed(String)
|
||||||
|
case invalidPage(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noJSONDocument:
|
||||||
|
return "No JSON document found in printtarg stdout."
|
||||||
|
case .wrongEvent(let event):
|
||||||
|
return "Unexpected JSON event \"\(event)\" — expected \"manifest\"."
|
||||||
|
case .decodeFailed(let reason):
|
||||||
|
return "printtarg manifest JSON failed to decode: \(reason)"
|
||||||
|
case .invalidPage(let reason):
|
||||||
|
return "printtarg manifest page is invalid: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts and decodes the `printtarg -u` manifest from the complete
|
||||||
|
/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest).
|
||||||
|
///
|
||||||
|
/// #68 invariant: the JSON is a structured document, not a brace-hunt.
|
||||||
|
/// Extraction is string/escape-aware — a `{` or `}` inside a quoted
|
||||||
|
/// filename can never corrupt the scan — and starts only at a `{` that
|
||||||
|
/// begins a trimmed stdout line.
|
||||||
|
public enum PrinttargManifestExtractor {
|
||||||
|
|
||||||
|
/// Finds the manifest object in accumulated stdout.
|
||||||
|
public static func manifest(from stdout: String) throws -> PrinttargManifest {
|
||||||
|
for block in jsonObjects(in: stdout) {
|
||||||
|
let data = Data(block.utf8)
|
||||||
|
guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard manifest.event == "manifest" else {
|
||||||
|
throw ManifestError.wrongEvent(manifest.event)
|
||||||
|
}
|
||||||
|
try validate(manifest)
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
if let first = jsonObjects(in: stdout).first,
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any],
|
||||||
|
let event = obj["event"] as? String {
|
||||||
|
throw ManifestError.wrongEvent(event)
|
||||||
|
}
|
||||||
|
throw ManifestError.noJSONDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validate(_ manifest: PrinttargManifest) throws {
|
||||||
|
for page in manifest.pages {
|
||||||
|
guard page.patches >= 0 else {
|
||||||
|
throw ManifestError.invalidPage("negative patch count \(page.patches)")
|
||||||
|
}
|
||||||
|
guard page.widthMm > 0, page.heightMm > 0 else {
|
||||||
|
throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)")
|
||||||
|
}
|
||||||
|
let name = page.filename
|
||||||
|
guard !name.isEmpty,
|
||||||
|
!name.hasPrefix("/"),
|
||||||
|
!name.contains("/"),
|
||||||
|
!name.contains("\\"),
|
||||||
|
!name.contains("..") else {
|
||||||
|
throw ManifestError.invalidPage("unsafe filename \"\(name)\"")
|
||||||
|
}
|
||||||
|
let ext = (name as NSString).pathExtension.lowercased()
|
||||||
|
guard ext == "tif" || ext == "tiff" else {
|
||||||
|
throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yields every complete top-level JSON object `{...}` found at a
|
||||||
|
/// trimmed line boundary, in document order. Depth tracking respects
|
||||||
|
/// quoted strings and backslash escapes.
|
||||||
|
static func jsonObjects(in text: String) -> [String] {
|
||||||
|
var out: [String] = []
|
||||||
|
let scalars = Array(text.unicodeScalars)
|
||||||
|
var i = 0
|
||||||
|
|
||||||
|
func isLineStart(_ idx: Int) -> Bool {
|
||||||
|
var j = idx - 1
|
||||||
|
while j >= 0 && scalars[j] != "\n" {
|
||||||
|
if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j -= 1
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
while i < scalars.count {
|
||||||
|
if scalars[i] == "{", isLineStart(i) {
|
||||||
|
var depth = 0
|
||||||
|
var inString = false
|
||||||
|
var escaped = false
|
||||||
|
var j = i
|
||||||
|
while j < scalars.count {
|
||||||
|
let c = scalars[j]
|
||||||
|
if inString {
|
||||||
|
if escaped { escaped = false }
|
||||||
|
else if c == "\\" { escaped = true }
|
||||||
|
else if c == "\"" { inString = false }
|
||||||
|
} else {
|
||||||
|
if c == "\"" { inString = true }
|
||||||
|
else if c == "{" { depth += 1 }
|
||||||
|
else if c == "}" {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 {
|
||||||
|
out.append(String(String.UnicodeScalarView(scalars[i...j])))
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `buildTargenArgs` validation (docs/04 §1.2).
|
||||||
|
public enum TargenArgError: LocalizedError, Equatable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPatchCount(Int)
|
||||||
|
case invalidWhitePatches(Int)
|
||||||
|
case invalidBlackPatches(Int)
|
||||||
|
case invalidInkLimit(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid target basename: \(name)"
|
||||||
|
case .invalidPatchCount(let count):
|
||||||
|
return "Patch count must be positive, got: \(count)"
|
||||||
|
case .invalidWhitePatches(let count):
|
||||||
|
return "White patches cannot be negative, got: \(count)"
|
||||||
|
case .invalidBlackPatches(let count):
|
||||||
|
return "Black patches cannot be negative, got: \(count)"
|
||||||
|
case .invalidInkLimit(let limit):
|
||||||
|
return "Ink limit must be between 1 and 400, got: \(limit)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2).
|
||||||
|
public enum TargenArgs {
|
||||||
|
|
||||||
|
/// Builds the exact command-line arguments for `targen`.
|
||||||
|
///
|
||||||
|
/// Invariants:
|
||||||
|
/// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4).
|
||||||
|
/// - Never emits `-u` (Argyll fork progress is not enabled for targen).
|
||||||
|
/// - Always emits `-f N` when patchCount > 0 (#44).
|
||||||
|
/// - White `-e`, Black `-B`.
|
||||||
|
/// - `-N` omitted when approximately 0.50.
|
||||||
|
/// - `-A` is emitted even at 0.10 (no default-skip).
|
||||||
|
/// - `-l` is CMYK only (1...400).
|
||||||
|
/// - `-V` omitted when approximately 1.0.
|
||||||
|
/// - `-p` omitted when non-positive or approximately 1.0.
|
||||||
|
/// - Basename is the last positional argument.
|
||||||
|
public static func build(config: TargenConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
guard config.patchCount > 0 else {
|
||||||
|
throw TargenArgError.invalidPatchCount(config.patchCount)
|
||||||
|
}
|
||||||
|
guard config.whitePatches >= 0 else {
|
||||||
|
throw TargenArgError.invalidWhitePatches(config.whitePatches)
|
||||||
|
}
|
||||||
|
guard config.blackPatches >= 0 else {
|
||||||
|
throw TargenArgError.invalidBlackPatches(config.blackPatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
var args: [String] = [
|
||||||
|
"-v",
|
||||||
|
"-d", config.colourSpace.dFlagValue,
|
||||||
|
"-f", "\(config.patchCount)",
|
||||||
|
"-e", "\(config.whitePatches)",
|
||||||
|
"-B", "\(config.blackPatches)"
|
||||||
|
]
|
||||||
|
|
||||||
|
if let g = config.greySteps, g > 0 {
|
||||||
|
args.append(contentsOf: ["-g", "\(g)"])
|
||||||
|
}
|
||||||
|
if let s = config.singleChannelSteps, s > 0 {
|
||||||
|
args.append(contentsOf: ["-s", "\(s)"])
|
||||||
|
}
|
||||||
|
if let n = config.neutralSteps, n > 0 {
|
||||||
|
args.append(contentsOf: ["-n", "\(n)"])
|
||||||
|
}
|
||||||
|
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
|
||||||
|
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
|
||||||
|
}
|
||||||
|
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
||||||
|
args.append(contentsOf: ["-c", c])
|
||||||
|
}
|
||||||
|
if config.ofpsHighQuality == true {
|
||||||
|
args.append("-G")
|
||||||
|
}
|
||||||
|
if let a = config.ofpsAdaptation {
|
||||||
|
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
|
||||||
|
}
|
||||||
|
if let algFlag = config.fullSpreadAlgorithm?.flag {
|
||||||
|
args.append(algFlag)
|
||||||
|
}
|
||||||
|
if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit {
|
||||||
|
guard (1...400).contains(inkLimit) else {
|
||||||
|
throw TargenArgError.invalidInkLimit(inkLimit)
|
||||||
|
}
|
||||||
|
args.append(contentsOf: ["-l", "\(inkLimit)"])
|
||||||
|
}
|
||||||
|
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
|
||||||
|
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
|
||||||
|
}
|
||||||
|
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
|
||||||
|
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Colour space for patch generation (docs/08, docs/04 §1.2).
|
||||||
|
public enum ColourSpace: String, Codable, Sendable, CaseIterable {
|
||||||
|
case rgb
|
||||||
|
case cmyk
|
||||||
|
|
||||||
|
/// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK.
|
||||||
|
public var dFlagValue: String {
|
||||||
|
switch self {
|
||||||
|
case .rgb: return "2"
|
||||||
|
case .cmyk: return "4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch count preset for Stage 1.
|
||||||
|
public enum PatchCountPreset: String, Codable, Sendable, CaseIterable {
|
||||||
|
case draft400 = "400"
|
||||||
|
case standard800 = "800"
|
||||||
|
case photo1500 = "1500"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
public var patchCount: Int? {
|
||||||
|
switch self {
|
||||||
|
case .draft400: return 400
|
||||||
|
case .standard800: return 800
|
||||||
|
case .photo1500: return 1500
|
||||||
|
case .custom: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var title: String {
|
||||||
|
switch self {
|
||||||
|
case .draft400: return "Draft (400)"
|
||||||
|
case .standard800: return "Standard (800)"
|
||||||
|
case .photo1500: return "Photo (1500)"
|
||||||
|
case .custom: return "Custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full spread patch distribution algorithm (docs/08).
|
||||||
|
/// Default is "ofps" (no flag emitted).
|
||||||
|
public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable {
|
||||||
|
case ofps = "ofps"
|
||||||
|
case target = "-t"
|
||||||
|
case random = "-r"
|
||||||
|
case uniformRandom = "-R"
|
||||||
|
case quasiRandom = "-q"
|
||||||
|
case uniformQuasiRandom = "-Q"
|
||||||
|
case invertedQuasiRandom = "-i"
|
||||||
|
case invertedUniformQuasiRandom = "-I"
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .ofps: return "OFPS (Default)"
|
||||||
|
case .target: return "Target (-t)"
|
||||||
|
case .random: return "Random (-r)"
|
||||||
|
case .uniformRandom: return "Uniform Random (-R)"
|
||||||
|
case .quasiRandom: return "Quasi-random (-q)"
|
||||||
|
case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)"
|
||||||
|
case .invertedQuasiRandom: return "Inverted Quasi-random (-i)"
|
||||||
|
case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var flag: String? {
|
||||||
|
switch self {
|
||||||
|
case .ofps: return nil
|
||||||
|
default: return rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preset JSON value: `"ofps"` or the bare flag letter
|
||||||
|
/// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) — docs/22.
|
||||||
|
public var presetValue: String {
|
||||||
|
switch self {
|
||||||
|
case .ofps: return "ofps"
|
||||||
|
default: return String(rawValue.dropFirst())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init?(presetValue: String) {
|
||||||
|
if presetValue == "ofps" {
|
||||||
|
self = .ofps
|
||||||
|
} else {
|
||||||
|
self.init(rawValue: "-" + presetValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2).
|
||||||
|
public struct TargenConfig: Codable, Equatable, Sendable {
|
||||||
|
public var colourSpace: ColourSpace
|
||||||
|
public var patchCount: Int
|
||||||
|
public var whitePatches: Int
|
||||||
|
public var blackPatches: Int
|
||||||
|
public var greySteps: Int?
|
||||||
|
public var singleChannelSteps: Int?
|
||||||
|
public var neutralSteps: Int?
|
||||||
|
public var neutralConcentration: Double?
|
||||||
|
public var preconditioningProfile: String?
|
||||||
|
public var ofpsHighQuality: Bool?
|
||||||
|
public var ofpsAdaptation: Double?
|
||||||
|
public var fullSpreadAlgorithm: FullSpreadAlgorithm?
|
||||||
|
public var totalInkLimit: Int?
|
||||||
|
public var darkEmphasis: Double?
|
||||||
|
public var devicePower: Double?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
colourSpace: ColourSpace = .rgb,
|
||||||
|
patchCount: Int = 800,
|
||||||
|
whitePatches: Int = 4,
|
||||||
|
blackPatches: Int = 4,
|
||||||
|
greySteps: Int? = nil,
|
||||||
|
singleChannelSteps: Int? = nil,
|
||||||
|
neutralSteps: Int? = nil,
|
||||||
|
neutralConcentration: Double? = nil,
|
||||||
|
preconditioningProfile: String? = nil,
|
||||||
|
ofpsHighQuality: Bool? = nil,
|
||||||
|
ofpsAdaptation: Double? = nil,
|
||||||
|
fullSpreadAlgorithm: FullSpreadAlgorithm? = nil,
|
||||||
|
totalInkLimit: Int? = nil,
|
||||||
|
darkEmphasis: Double? = nil,
|
||||||
|
devicePower: Double? = nil,
|
||||||
|
basename: String = "",
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.whitePatches = whitePatches
|
||||||
|
self.blackPatches = blackPatches
|
||||||
|
self.greySteps = greySteps
|
||||||
|
self.singleChannelSteps = singleChannelSteps
|
||||||
|
self.neutralSteps = neutralSteps
|
||||||
|
self.neutralConcentration = neutralConcentration
|
||||||
|
self.preconditioningProfile = preconditioningProfile
|
||||||
|
self.ofpsHighQuality = ofpsHighQuality
|
||||||
|
self.ofpsAdaptation = ofpsAdaptation
|
||||||
|
self.fullSpreadAlgorithm = fullSpreadAlgorithm
|
||||||
|
self.totalInkLimit = totalInkLimit
|
||||||
|
self.darkEmphasis = darkEmphasis
|
||||||
|
self.devicePower = devicePower
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors that can occur while parsing CGATS-like data.
|
||||||
|
public enum CGATSParseError: Error, Equatable {
|
||||||
|
case emptyFile
|
||||||
|
case missingBeginDataFormat
|
||||||
|
case missingEndDataFormat
|
||||||
|
case missingBeginData
|
||||||
|
case missingEndData
|
||||||
|
case missingNumberOfFields
|
||||||
|
case missingNumberOfSets
|
||||||
|
case unknownFieldName(String)
|
||||||
|
case malformedRow(line: Int, reason: String)
|
||||||
|
case nonNumericValue(field: String, value: String, line: Int)
|
||||||
|
case outOfBoundsValue(field: String, value: Double, line: Int)
|
||||||
|
case implausibleValue(field: String, value: Double, line: Int)
|
||||||
|
case incorrectArity(line: Int, expected: Int, got: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a CGATS dataset, keyed by canonical field name.
|
||||||
|
public struct CGATSSample: Sendable, Equatable {
|
||||||
|
public var id: String
|
||||||
|
public var loc: String?
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed CGATS / CTI3 / CSV dataset.
|
||||||
|
public struct CGATSDataset: Sendable, Equatable {
|
||||||
|
public var format: CGATSFormat
|
||||||
|
public var keywords: [String: String]
|
||||||
|
public var fieldNames: [String]
|
||||||
|
public var samples: [CGATSSample]
|
||||||
|
public var colorRep: String?
|
||||||
|
public var deviceClass: String?
|
||||||
|
public var targetInstrument: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
format: CGATSFormat,
|
||||||
|
keywords: [String: String] = [:],
|
||||||
|
fieldNames: [String] = [],
|
||||||
|
samples: [CGATSSample] = [],
|
||||||
|
colorRep: String? = nil,
|
||||||
|
deviceClass: String? = nil,
|
||||||
|
targetInstrument: String? = nil
|
||||||
|
) {
|
||||||
|
self.format = format
|
||||||
|
self.keywords = keywords
|
||||||
|
self.fieldNames = fieldNames
|
||||||
|
self.samples = samples
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.deviceClass = deviceClass
|
||||||
|
self.targetInstrument = targetInstrument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CGATSFormat: String, Sendable, Equatable {
|
||||||
|
case cti3 = "CTI3"
|
||||||
|
case cgats17 = "CGATS.17"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
|
||||||
|
public enum CGATSParser {
|
||||||
|
|
||||||
|
/// Parse the contents of a CGATS-like file.
|
||||||
|
public static func parse(
|
||||||
|
_ contents: String,
|
||||||
|
sourceURL: URL? = nil
|
||||||
|
) throws(CGATSParseError) -> CGATSDataset {
|
||||||
|
guard !contents.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.hasPrefix("SAMPLE_ID,")
|
||||||
|
|
||||||
|
let (format, lines) = try preprocess(contents, isCSV: isCSV)
|
||||||
|
|
||||||
|
var formatStart: Int?
|
||||||
|
var formatEnd: Int?
|
||||||
|
var dataStart: Int?
|
||||||
|
var dataEnd: Int?
|
||||||
|
var keywords = [String: String]()
|
||||||
|
|
||||||
|
for (index, line) in lines.enumerated() {
|
||||||
|
switch Self.normalizedKeyword(line) {
|
||||||
|
case "BEGIN_DATA_FORMAT": formatStart = index
|
||||||
|
case "END_DATA_FORMAT": formatEnd = index
|
||||||
|
case "BEGIN_DATA": dataStart = index
|
||||||
|
case "END_DATA": dataEnd = index
|
||||||
|
default:
|
||||||
|
if let (key, value) = parseKeyword(line) {
|
||||||
|
keywords[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
|
throw .missingBeginDataFormat
|
||||||
|
}
|
||||||
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
|
throw .missingBeginData
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
|
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
|
||||||
|
|
||||||
|
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
|
||||||
|
numberOfFields != fieldNames.count {
|
||||||
|
// Warn only; the data format line is the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_FIELDS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
|
||||||
|
numberOfSets != dataEnd - dataStart - 1 {
|
||||||
|
// Warn only; the actual rows are the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_SETS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawSample {
|
||||||
|
var id: String
|
||||||
|
var loc: String?
|
||||||
|
var numbers: [String: Double] = [:]
|
||||||
|
var strings: [String: String] = [:]
|
||||||
|
var lineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSamples = [RawSample]()
|
||||||
|
var groupMax: [String: Double] = [:]
|
||||||
|
|
||||||
|
for offset in 1...(dataEnd - dataStart - 1) {
|
||||||
|
let lineIndex = dataStart + offset
|
||||||
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
|
guard rawRow.count == fieldNames.count else {
|
||||||
|
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
|
for (i, name) in fieldNames.enumerated() {
|
||||||
|
let raw = stripInlineComment(rawRow[i])
|
||||||
|
if isNumericField(name) {
|
||||||
|
let cleaned = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let number = parseNumber(cleaned) {
|
||||||
|
sample.numbers[name] = number
|
||||||
|
if let group = deviceGroup(name) {
|
||||||
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
|
}
|
||||||
|
} else if !cleaned.isEmpty {
|
||||||
|
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sample.strings[name] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
|
||||||
|
sample.loc = sample.strings["SAMPLE_LOC"]
|
||||||
|
rawSamples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples = [CGATSSample]()
|
||||||
|
for raw in rawSamples {
|
||||||
|
var values = raw.strings
|
||||||
|
for (name, number) in raw.numbers {
|
||||||
|
var scaled = number
|
||||||
|
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
|
||||||
|
scaled = number / 2.55
|
||||||
|
}
|
||||||
|
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
|
||||||
|
// Keep lookups by canonical keys, but also preserve original aliases.
|
||||||
|
let rawRow = splitFields(lines[raw.lineIndex])
|
||||||
|
for (i, rawName) in rawFieldNames.enumerated() {
|
||||||
|
let canonical = canonicalFieldName(rawName)
|
||||||
|
if canonical != rawName {
|
||||||
|
sample.values[rawName] = rawRow[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
samples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
|
||||||
|
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
|
||||||
|
|
||||||
|
return CGATSDataset(
|
||||||
|
format: format,
|
||||||
|
keywords: keywords,
|
||||||
|
fieldNames: fieldNames,
|
||||||
|
samples: samples,
|
||||||
|
colorRep: colorRep,
|
||||||
|
deviceClass: deviceClass,
|
||||||
|
targetInstrument: keywords["TARGET_INSTRUMENT"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a URL (throws as `Error` for public callers).
|
||||||
|
public static func parse(url: URL) throws -> CGATSDataset {
|
||||||
|
let contents = try String(contentsOf: url)
|
||||||
|
return try parse(contents, sourceURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func preprocess(
|
||||||
|
_ contents: String,
|
||||||
|
isCSV: Bool
|
||||||
|
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||||
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
var format: CGATSFormat?
|
||||||
|
for var line in allLines {
|
||||||
|
line = stripComment(line)
|
||||||
|
line = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !line.isEmpty else { continue }
|
||||||
|
|
||||||
|
if format == nil {
|
||||||
|
if line.hasPrefix("CTI3") { format = .cti3 }
|
||||||
|
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
|
||||||
|
else if isCSV { format = .csv }
|
||||||
|
}
|
||||||
|
|
||||||
|
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
|
||||||
|
line == "BEGIN_DATA" || line == "END_DATA" ||
|
||||||
|
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
|
||||||
|
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
|
||||||
|
// These are exact keywords; keep them intact.
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lines.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
if let format, format == .csv,
|
||||||
|
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
|
||||||
|
let header = lines[0]
|
||||||
|
let data = lines.dropFirst()
|
||||||
|
lines = [
|
||||||
|
"CTI3",
|
||||||
|
"BEGIN_DATA_FORMAT",
|
||||||
|
header,
|
||||||
|
"END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA"
|
||||||
|
] + Array(data) + [
|
||||||
|
"END_DATA"
|
||||||
|
]
|
||||||
|
return (.csv, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (format ?? .cti3, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripComment(_ line: String) -> String {
|
||||||
|
if let range = line.range(of: "#") {
|
||||||
|
return String(line[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripInlineComment(_ token: String) -> String {
|
||||||
|
if let range = token.range(of: "#") {
|
||||||
|
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitFields(_ line: String) -> [String] {
|
||||||
|
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
|
||||||
|
if line.contains(",") {
|
||||||
|
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
|
||||||
|
// KEYWORD value or KEYWORD "value"
|
||||||
|
let tokens = splitFields(line)
|
||||||
|
guard let key = tokens.first else { return nil }
|
||||||
|
|
||||||
|
// Data-boundary keywords are not value keywords.
|
||||||
|
let boundaryKeys = Set([
|
||||||
|
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA", "END_DATA"
|
||||||
|
])
|
||||||
|
guard !boundaryKeys.contains(key) else { return nil }
|
||||||
|
|
||||||
|
let rawValue = tokens.dropFirst().joined(separator: " ")
|
||||||
|
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
return (key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedKeyword(_ line: String) -> String {
|
||||||
|
line.uppercased().trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field name normalization
|
||||||
|
|
||||||
|
private static func canonicalFieldName(_ raw: String) -> String {
|
||||||
|
let upper = raw.uppercased()
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "-", with: "_")
|
||||||
|
switch upper {
|
||||||
|
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
|
||||||
|
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
|
||||||
|
case "SAMPLE_NAME": return "SAMPLE_ID"
|
||||||
|
case "LAB_L", "L*", "L_AB": return "LAB_L"
|
||||||
|
case "LAB_A", "A*", "A_AB": return "LAB_A"
|
||||||
|
case "LAB_B", "B*", "B_AB": return "LAB_B"
|
||||||
|
case "XYZ_X", "X": return "XYZ_X"
|
||||||
|
case "XYZ_Y", "Y": return "XYZ_Y"
|
||||||
|
case "XYZ_Z", "Z": return "XYZ_Z"
|
||||||
|
default: return upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isNumericField(_ name: String) -> Bool {
|
||||||
|
let numericNames: Set = [
|
||||||
|
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
|
||||||
|
]
|
||||||
|
return !numericNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
|
||||||
|
var number = value
|
||||||
|
|
||||||
|
// Plausibility checks for Lab and XYZ.
|
||||||
|
if field == "LAB_L" { number = max(0, min(160, number)) }
|
||||||
|
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
|
||||||
|
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
|
||||||
|
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceGroup(_ name: String) -> String? {
|
||||||
|
if name.hasPrefix("RGB_") { return "RGB" }
|
||||||
|
if name.hasPrefix("CMYK_") { return "CMYK" }
|
||||||
|
if name.hasPrefix("DEVICE_") { return "DEVICE" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferColorRep(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferDeviceClass(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
|
||||||
|
return "OUTPUT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable summary of an imported CGATS dataset.
|
||||||
|
public struct CGATSSummary: Sendable, Equatable {
|
||||||
|
public let patchCount: Int
|
||||||
|
public let colorSpace: String?
|
||||||
|
public let deviceClass: String?
|
||||||
|
public let hasSpectral: Bool
|
||||||
|
public let previewRows: [String]
|
||||||
|
|
||||||
|
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
|
||||||
|
self.patchCount = dataset.samples.count
|
||||||
|
self.colorSpace = dataset.colorRep
|
||||||
|
self.deviceClass = dataset.deviceClass
|
||||||
|
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
|
||||||
|
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
|
||||||
|
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from writing a canonical `.ti3` dataset.
|
||||||
|
public enum CGATSWriterError: Error, Equatable {
|
||||||
|
case noSamples
|
||||||
|
case missingRequiredField(String)
|
||||||
|
case invalidValue(field: String, value: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
|
||||||
|
public enum CGATSWriter {
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset) throws -> String {
|
||||||
|
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
|
||||||
|
throw CGATSWriterError.noSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.append(dataset.format.rawValue)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
|
||||||
|
if let colorRep = dataset.colorRep {
|
||||||
|
lines.append("COLOR_REP \"\(colorRep)\"")
|
||||||
|
}
|
||||||
|
if let deviceClass = dataset.deviceClass {
|
||||||
|
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
|
||||||
|
}
|
||||||
|
if let instrument = dataset.targetInstrument {
|
||||||
|
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
|
||||||
|
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA_FORMAT")
|
||||||
|
lines.append(dataset.fieldNames.joined(separator: "\t"))
|
||||||
|
lines.append("END_DATA_FORMAT")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA")
|
||||||
|
for sample in dataset.samples {
|
||||||
|
let row = try dataset.fieldNames.map { field in
|
||||||
|
guard let raw = sample.values[field], !raw.isEmpty else {
|
||||||
|
throw CGATSWriterError.missingRequiredField(field)
|
||||||
|
}
|
||||||
|
// Normalize numeric fields to a compact decimal.
|
||||||
|
if isNumeric(field) {
|
||||||
|
return normalizedNumber(raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
lines.append(row.joined(separator: "\t"))
|
||||||
|
}
|
||||||
|
lines.append("END_DATA")
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
|
||||||
|
let text = try write(dataset)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func isNumeric(_ field: String) -> Bool {
|
||||||
|
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
|
||||||
|
return !nonNumeric.contains(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedNumber(_ raw: String) -> String {
|
||||||
|
guard let number = Double(raw) else { return raw }
|
||||||
|
if number == floor(number) {
|
||||||
|
return String(format: "%.0f", number)
|
||||||
|
}
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Small host-side file helpers (issue #6).
|
||||||
|
public enum ArtefactFiles {
|
||||||
|
|
||||||
|
/// `get_default_working_dir` — `resolveSafeCwd(nil)`.
|
||||||
|
public static func defaultWorkingDirectory() -> URL {
|
||||||
|
PathSecurity.resolveSafeCwd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `read_file_base64` — for **text artefacts** the UI needs verbatim
|
||||||
|
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
|
||||||
|
/// go through `TiffPreview` instead.
|
||||||
|
public static func readBase64(_ url: URL) throws -> String {
|
||||||
|
try Data(contentsOf: url).base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `get_app_info` — version, build, and build date for the About dialog.
|
||||||
|
public static func appInfo(
|
||||||
|
bundle: Bundle = .main
|
||||||
|
) -> (version: String, build: String, buildDate: String) {
|
||||||
|
let info = bundle.infoDictionary ?? [:]
|
||||||
|
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
|
let build = info["CFBundleVersion"] as? String ?? "0"
|
||||||
|
|
||||||
|
let url = bundle.executableURL ?? bundle.bundleURL
|
||||||
|
let buildDate: String
|
||||||
|
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
|
||||||
|
let date = values.contentModificationDate {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
buildDate = formatter.string(from: date)
|
||||||
|
} else {
|
||||||
|
buildDate = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (version, build, buildDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
|
||||||
|
public struct StageArtefacts: Sendable, Equatable {
|
||||||
|
/// `<basename>.ti1` exists (Stage 1 done → unlocks Stage 2).
|
||||||
|
public var stage1Complete = false
|
||||||
|
/// `<basename>.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
|
||||||
|
public var stage2Complete = false
|
||||||
|
/// `<basename>.ti3` exists (Stage 3 done → unlocks Stage 4).
|
||||||
|
public var stage3Complete = false
|
||||||
|
/// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5).
|
||||||
|
public var stage4Complete = false
|
||||||
|
/// Absolute path of the profile file when present.
|
||||||
|
public var profilePath: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stage1Complete: Bool = false,
|
||||||
|
stage2Complete: Bool = false,
|
||||||
|
stage3Complete: Bool = false,
|
||||||
|
stage4Complete: Bool = false,
|
||||||
|
profilePath: URL? = nil
|
||||||
|
) {
|
||||||
|
self.stage1Complete = stage1Complete
|
||||||
|
self.stage2Complete = stage2Complete
|
||||||
|
self.stage3Complete = stage3Complete
|
||||||
|
self.stage4Complete = stage4Complete
|
||||||
|
self.profilePath = profilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||||
|
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
|
||||||
|
public enum ArtefactProbe {
|
||||||
|
|
||||||
|
/// `verify_stage_artefacts` — the gating truth source.
|
||||||
|
public static func verify(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> StageArtefacts {
|
||||||
|
var out = StageArtefacts()
|
||||||
|
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
|
||||||
|
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
|
||||||
|
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
|
||||||
|
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||||
|
out.stage4Complete = true
|
||||||
|
out.profilePath = profile
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<cwd>/<basename>.<ext>` — the canonical artefact URL.
|
||||||
|
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
|
||||||
|
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile extension resolution (#69): existing `.icm` wins over
|
||||||
|
/// `.icc`; when neither exists the macOS default is `.icc`.
|
||||||
|
/// (`profcheck`/`iccgamut` swap extension when the requested path is
|
||||||
|
/// missing.)
|
||||||
|
public static func resolveProfile(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
let icm = artefact(basename, "icm", cwd)
|
||||||
|
if exists(icm, fm: fileManager) { return icm }
|
||||||
|
let icc = artefact(basename, "icc", cwd)
|
||||||
|
if exists(icc, fm: fileManager) { return icc }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default extension for a *new* profile on macOS (#69).
|
||||||
|
public static let defaultProfileExtension = "icc"
|
||||||
|
|
||||||
|
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
|
||||||
|
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
|
||||||
|
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` … and
|
||||||
|
/// `<basename>_NN.tif` (manifest naming).
|
||||||
|
public static func existingArtefacts(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefixes = [basename + ".", "CAL_" + basename + "."]
|
||||||
|
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
|
||||||
|
let passPrefix = basename + "_pass"
|
||||||
|
let tifStemPrefix = basename + "_"
|
||||||
|
let calPrefix = "CAL_" + basename
|
||||||
|
|
||||||
|
return entries.filter { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
guard suffixes.contains(ext) else { return false }
|
||||||
|
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
|
||||||
|
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
|
||||||
|
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
|
||||||
|
if name.hasPrefix(calPrefix) { return true }
|
||||||
|
return false
|
||||||
|
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func exists(_ url: URL, fm: FileManager) -> Bool {
|
||||||
|
fm.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Atomic `.tmp`-then-rename file writes — the convention used by
|
||||||
|
/// settings.json, verification_history.json and wizard_state.json
|
||||||
|
/// (docs/02 §Persistence, #213).
|
||||||
|
public enum AtomicFileWriter {
|
||||||
|
|
||||||
|
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
|
||||||
|
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
|
||||||
|
public static func write(_ data: Data, to url: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let dir = url.deletingLastPathComponent()
|
||||||
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let tmp = url.appendingPathExtension("tmp")
|
||||||
|
do {
|
||||||
|
try data.write(to: tmp, options: [])
|
||||||
|
// replaceItemAt handles same-volume atomic swap and removes
|
||||||
|
// the destination cleanly; fall back to remove+move.
|
||||||
|
if fm.fileExists(atPath: url.path) {
|
||||||
|
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmp, to: url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmp)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ text: String, to url: URL) throws {
|
||||||
|
try write(Data(text.utf8), to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Basename sanitisation and safe working-directory resolution
|
||||||
|
/// (docs/02 §Working directory, docs/06 §Empty cwd).
|
||||||
|
public enum PathSecurity {
|
||||||
|
|
||||||
|
public enum Error: Swift.Error, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basenames must not contain `/`, `\`, or `..` and must be
|
||||||
|
/// non-empty. Never invent a default basename (#60).
|
||||||
|
public static func isValidBasename(_ name: String) -> Bool {
|
||||||
|
guard !name.isEmpty else { return false }
|
||||||
|
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func sanitizeBasename(_ name: String) throws -> String {
|
||||||
|
guard isValidBasename(name) else {
|
||||||
|
throw Error.invalidBasename(name)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory →
|
||||||
|
/// Documents → Home → app-data. Never returns an empty/nil cwd.
|
||||||
|
public static func resolveSafeCwd(
|
||||||
|
_ explicit: URL?,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL {
|
||||||
|
if let explicit,
|
||||||
|
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
let candidates: [URL?] = [
|
||||||
|
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||||
|
fileManager.homeDirectoryForCurrentUser,
|
||||||
|
AppPaths.appDataDir,
|
||||||
|
]
|
||||||
|
for candidate in candidates {
|
||||||
|
guard let url = candidate else { continue }
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: app-data, created unconditionally.
|
||||||
|
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
|
||||||
|
return AppPaths.appDataDir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
|
||||||
|
/// `parse_ti2_header` reads only CGATS keyword lines — the data grid
|
||||||
|
/// itself belongs to issue #30.
|
||||||
|
public struct Ti2Header: Sendable, Equatable {
|
||||||
|
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
|
||||||
|
public var instrument: String?
|
||||||
|
/// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is
|
||||||
|
/// the CGATS column count, *not* the patch count.
|
||||||
|
public var patchCount: Int?
|
||||||
|
/// `NUMBER_OF_PAGES`.
|
||||||
|
public var pageCount: Int?
|
||||||
|
/// A sibling `<stem>.ti1` exists next to the parsed file.
|
||||||
|
public var hasSiblingTi1 = false
|
||||||
|
|
||||||
|
public static func parse(
|
||||||
|
_ url: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Ti2Header {
|
||||||
|
var header = Ti2Header()
|
||||||
|
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
|
||||||
|
guard let space = line.firstIndex(of: " ") else { continue }
|
||||||
|
let key = String(line[..<space])
|
||||||
|
let value = String(line[line.index(after: space)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
switch key {
|
||||||
|
case "TARGET_INSTRUMENT":
|
||||||
|
header.instrument = value
|
||||||
|
case "NUMBER_OF_SETS":
|
||||||
|
header.patchCount = Int(value)
|
||||||
|
case "NUMBER_OF_PAGES":
|
||||||
|
header.pageCount = Int(value)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stem = url.deletingPathExtension()
|
||||||
|
header.hasSiblingTi1 = fileManager.fileExists(
|
||||||
|
atPath: stem.appendingPathExtension("ti1").path
|
||||||
|
)
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// TIFF → PNG preview for the Stage 2 gallery (#58): decode on the host
|
||||||
|
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
|
||||||
|
/// bytes to the UI.
|
||||||
|
public enum TiffPreview {
|
||||||
|
|
||||||
|
public static let maxEdge: Int = 1200
|
||||||
|
|
||||||
|
/// Returns PNG data for the first page of a TIFF, or `nil` when the
|
||||||
|
/// file cannot be decoded.
|
||||||
|
public static func previewPNG(
|
||||||
|
tiff url: URL,
|
||||||
|
maxEdge: Int = Self.maxEdge
|
||||||
|
) -> Data? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
source, 0, options as CFDictionary
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
let out = NSMutableData()
|
||||||
|
guard let dest = CGImageDestinationCreateWithData(
|
||||||
|
out, UTType.png.identifier as CFString, 1, nil
|
||||||
|
) else { return nil }
|
||||||
|
CGImageDestinationAddImage(dest, image, nil)
|
||||||
|
guard CGImageDestinationFinalize(dest) else { return nil }
|
||||||
|
return out as Data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
|
||||||
var rank: Int {
|
var rank: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .error: return 0
|
case .error: return 0
|
||||||
@@ -26,16 +27,19 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Central logger. For M1 PR2 this writes to `os.Logger` only;
|
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
|
||||||
/// issue #5 adds the rolling file sink and runtime `setLevel`.
|
/// gated at write time so a settings save takes effect immediately
|
||||||
|
/// (#158).
|
||||||
public struct AppLogger: Sendable {
|
public struct AppLogger: Sendable {
|
||||||
public static let shared = AppLogger(category: "app")
|
public static let shared = AppLogger(category: "app")
|
||||||
|
|
||||||
private let osLog: Logger
|
private let osLog: Logger
|
||||||
|
private let sink: LogSink
|
||||||
public let category: String
|
public let category: String
|
||||||
|
|
||||||
public init(category: String) {
|
public init(category: String, sink: LogSink = .shared) {
|
||||||
self.category = category
|
self.category = category
|
||||||
|
self.sink = sink
|
||||||
self.osLog = Logger(
|
self.osLog = Logger(
|
||||||
subsystem: AppPaths.bundleIdentifier,
|
subsystem: AppPaths.bundleIdentifier,
|
||||||
category: category
|
category: category
|
||||||
@@ -44,7 +48,10 @@ public struct AppLogger: Sendable {
|
|||||||
|
|
||||||
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
||||||
let text = LogSanitizer.sanitize(message())
|
let text = LogSanitizer.sanitize(message())
|
||||||
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
if level.rank <= sink.level.rank {
|
||||||
|
osLog.log(level: level.osType, "\(text, privacy: .public)")
|
||||||
|
}
|
||||||
|
sink.write(level: level, category: category, message: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
|
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Rolling file sink for `AppLogger` — `~/Library/Logs/<bundle>/
|
||||||
|
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
|
||||||
|
/// (`iccery.log.1` … `iccery.log.5`).
|
||||||
|
///
|
||||||
|
/// The minimum level is **runtime state** (#158): `setLevel` takes
|
||||||
|
/// effect immediately — at startup and on every settings save.
|
||||||
|
public final class LogSink: @unchecked Sendable {
|
||||||
|
|
||||||
|
public static let shared = LogSink(fileURL: AppPaths.logFile)
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let fileURL: URL
|
||||||
|
private var minimumLevel: LogLevel
|
||||||
|
private var handle: FileHandle?
|
||||||
|
|
||||||
|
/// 5 MiB per segment, 5 historical segments kept.
|
||||||
|
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
|
||||||
|
public static let keptSegments = 5
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL = AppPaths.logFile,
|
||||||
|
minimumLevel: LogLevel? = nil
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
#if DEBUG
|
||||||
|
self.minimumLevel = minimumLevel ?? .debug
|
||||||
|
#else
|
||||||
|
self.minimumLevel = minimumLevel ?? .info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public var level: LogLevel {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return minimumLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied at startup AND on every settings save (issue #5, #158).
|
||||||
|
public func setLevel(_ level: LogLevel) {
|
||||||
|
lock.lock()
|
||||||
|
minimumLevel = level
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` → DEBUG-build default (.debug) / release (.info).
|
||||||
|
public func applySettings(_ settings: AppSettings) {
|
||||||
|
setLevel(settings.effectiveLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func shouldLog(_ level: LogLevel) -> Bool {
|
||||||
|
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writing
|
||||||
|
|
||||||
|
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
|
||||||
|
/// rotating first when the active segment exceeds 5 MiB.
|
||||||
|
public func write(level: LogLevel, category: String, message: String) {
|
||||||
|
guard shouldLog(level) else { return }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
rotateIfNeeded()
|
||||||
|
openIfNeeded()
|
||||||
|
let stamp = Self.timestamp()
|
||||||
|
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
handle?.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static func timestamp() -> String {
|
||||||
|
formatter.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openIfNeeded() {
|
||||||
|
guard handle == nil else { return }
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
||||||
|
}
|
||||||
|
handle = try? FileHandle(forWritingTo: fileURL)
|
||||||
|
try? handle?.seekToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the
|
||||||
|
/// writer. Oldest segment is deleted.
|
||||||
|
private func rotateIfNeeded() {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path),
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||||
|
let size = attrs[.size] as? UInt64,
|
||||||
|
size >= Self.maxSegmentBytes
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
try? handle?.close()
|
||||||
|
handle = nil
|
||||||
|
let fm = FileManager.default
|
||||||
|
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
|
||||||
|
try? fm.removeItem(at: oldest)
|
||||||
|
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
|
||||||
|
let src = fileURL.appendingPathExtension("\(i)")
|
||||||
|
let dst = fileURL.appendingPathExtension("\(i + 1)")
|
||||||
|
if fm.fileExists(atPath: src.path) {
|
||||||
|
try? fm.moveItem(at: src, to: dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tail of the active log for the settings dialog's "copy excerpt".
|
||||||
|
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else { return "" }
|
||||||
|
let slice = data.suffix(maxBytes)
|
||||||
|
return String(decoding: slice, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `average` argv construction.
|
||||||
|
public enum AverageArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPassCount(Int)
|
||||||
|
case outputCollidesWithInput
|
||||||
|
case pathOutsideCwd(URL)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid basename for average: \(name)"
|
||||||
|
case .invalidPassCount(let count):
|
||||||
|
return "Average requires at least 2 pass files, got \(count)"
|
||||||
|
case .outputCollidesWithInput:
|
||||||
|
return "Average output filename collides with one of the inputs"
|
||||||
|
case .pathOutsideCwd(let url):
|
||||||
|
return "Pass or output file is outside the working directory: \(url.path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for an `average` run.
|
||||||
|
public struct AverageConfig: Sendable, Equatable {
|
||||||
|
public let workingDirectory: URL
|
||||||
|
public let basename: String
|
||||||
|
public let passFiles: [URL]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
workingDirectory: URL,
|
||||||
|
basename: String,
|
||||||
|
passFiles: [URL]
|
||||||
|
) {
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.basename = basename
|
||||||
|
self.passFiles = passFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `average` tool.
|
||||||
|
public enum AverageArgs {
|
||||||
|
|
||||||
|
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
|
||||||
|
public static func build(config: AverageConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
guard config.passFiles.count >= 2 else {
|
||||||
|
throw AverageArgError.invalidPassCount(config.passFiles.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = config.workingDirectory
|
||||||
|
.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
|
||||||
|
var inputNames: [String] = []
|
||||||
|
for url in config.passFiles {
|
||||||
|
try validate(url, isIn: config.workingDirectory)
|
||||||
|
inputNames.append(url.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
try validate(output, isIn: config.workingDirectory)
|
||||||
|
let outputName = output.lastPathComponent
|
||||||
|
|
||||||
|
guard !inputNames.contains(outputName) else {
|
||||||
|
throw AverageArgError.outputCollidesWithInput
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["-v"] + inputNames + [outputName]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validate(_ url: URL, isIn cwd: URL) throws {
|
||||||
|
let cwdPath = cwd.standardizedFileURL.path
|
||||||
|
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
|
||||||
|
guard urlPath == cwdPath else {
|
||||||
|
throw AverageArgError.pathOutsideCwd(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `chartread` tool.
|
||||||
|
public enum ChartreadArgs {
|
||||||
|
|
||||||
|
/// Builds `chartread` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// - Always `-v -u`.
|
||||||
|
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
|
||||||
|
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
|
||||||
|
/// - Basename is the last positional argument and is sanitized.
|
||||||
|
public static func build(config: ChartreadConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = ["-v", "-u"]
|
||||||
|
|
||||||
|
if let port = config.selectedPort, port > 1 {
|
||||||
|
args.append(contentsOf: ["-c", "\(port)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enableLEDs {
|
||||||
|
args.append(contentsOf: ["-Y", "l"])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Discrete states for the `chartread` interaction.
|
||||||
|
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
|
||||||
|
case idle
|
||||||
|
case calibrating
|
||||||
|
case awaitingStrip
|
||||||
|
case reading
|
||||||
|
case allStripsRead
|
||||||
|
case warning
|
||||||
|
case promptContinue
|
||||||
|
case tablePlaceSheet
|
||||||
|
case tableAlign
|
||||||
|
case error
|
||||||
|
case finished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra metadata produced by classifying a single `chartread` stdout line.
|
||||||
|
public struct ChartreadClassifyResult: Sendable, Equatable {
|
||||||
|
public let state: ChartreadState
|
||||||
|
/// Whether this line is an informational "remove last sheet" notice.
|
||||||
|
public let isRemoveSheetNotice: Bool
|
||||||
|
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
|
||||||
|
public let sheetNumber: Int?
|
||||||
|
public let sheetTotal: Int?
|
||||||
|
/// Fiducial patch name from XY "locate patch X with the sight".
|
||||||
|
public let alignmentPatch: String?
|
||||||
|
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
|
||||||
|
public let requestedWarningKey: String?
|
||||||
|
/// Whether the line is a continuation of a multi-line XY prompt.
|
||||||
|
public let isTableContinuation: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: ChartreadState,
|
||||||
|
isRemoveSheetNotice: Bool = false,
|
||||||
|
sheetNumber: Int? = nil,
|
||||||
|
sheetTotal: Int? = nil,
|
||||||
|
alignmentPatch: String? = nil,
|
||||||
|
requestedWarningKey: String? = nil,
|
||||||
|
isTableContinuation: Bool = false
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.isRemoveSheetNotice = isRemoveSheetNotice
|
||||||
|
self.sheetNumber = sheetNumber
|
||||||
|
self.sheetTotal = sheetTotal
|
||||||
|
self.alignmentPatch = alignmentPatch
|
||||||
|
self.requestedWarningKey = requestedWarningKey
|
||||||
|
self.isTableContinuation = isTableContinuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure line classifier for `chartread` stdout.
|
||||||
|
///
|
||||||
|
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
|
||||||
|
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
|
||||||
|
public enum ChartreadClassifier {
|
||||||
|
|
||||||
|
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
for matcher in matchers(previousState) {
|
||||||
|
if let result = matcher(text, previousState) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
|
||||||
|
[
|
||||||
|
removeSheetNotice,
|
||||||
|
sheetReadOk,
|
||||||
|
locatePatch,
|
||||||
|
placeSheet,
|
||||||
|
continuation(previous),
|
||||||
|
done,
|
||||||
|
warning,
|
||||||
|
calibration,
|
||||||
|
awaitingStrip,
|
||||||
|
reading,
|
||||||
|
error
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. "Please remove last sheet from table" — info only.
|
||||||
|
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. "Sheet N of M read OK".
|
||||||
|
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: previous,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. "locate patch X with the sight".
|
||||||
|
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
|
||||||
|
!match.0.isEmpty else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tableAlign,
|
||||||
|
alignmentPatch: match.0.uppercased()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. "place sheet N of M" or "remove previous sheet".
|
||||||
|
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tablePlaceSheet,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if text.contains("remove previous sheet") || text.contains("place sheet") {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. "hit return to continue" — sticky if already in a table state.
|
||||||
|
private static func continuation(_ previous: ChartreadState) -> Matcher {
|
||||||
|
return { text, _ in
|
||||||
|
guard text.contains("hit return to continue")
|
||||||
|
|| text.contains("hit any key to continue")
|
||||||
|
|| text.contains("hit space to continue")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if case .tablePlaceSheet = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
if case .tableAlign = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Done / all read.
|
||||||
|
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let phrases = [
|
||||||
|
"'d' if/when done", "d to finish/save", "all strips/patches read",
|
||||||
|
"all strips read", "all patches read", "done reading",
|
||||||
|
"'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
|
||||||
|
]
|
||||||
|
if phrases.contains(where: { text.contains($0) }) {
|
||||||
|
return ChartreadClassifyResult(state: .allStripsRead)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Warnings / prompts needing a key.
|
||||||
|
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text
|
||||||
|
let warningSignals = [
|
||||||
|
"(warning)", "use it anyway", "seem to have read strip",
|
||||||
|
"unexpected response", "try again", "do you want to",
|
||||||
|
"abort ? - are you sure", "are you sure"
|
||||||
|
]
|
||||||
|
|
||||||
|
let isWarningPrompt =
|
||||||
|
warningSignals.contains(where: { lower.contains($0) })
|
||||||
|
|| lower.contains("(y/n)")
|
||||||
|
|| lower.contains("'y' or 'n'")
|
||||||
|
|| lower.contains("?")
|
||||||
|
|
||||||
|
guard isWarningPrompt else { return nil }
|
||||||
|
|
||||||
|
var key: String?
|
||||||
|
if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
|
||||||
|
// Default to asking the user; no automatic key.
|
||||||
|
key = nil
|
||||||
|
} else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
|
||||||
|
key = "y"
|
||||||
|
} else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
|
||||||
|
key = "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Calibration / place reference / white / standard tile.
|
||||||
|
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
|
||||||
|
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
|
||||||
|
let hasLocate = lowercased.contains("locate patch")
|
||||||
|
|
||||||
|
guard placeTokens.contains(where: { lowercased.contains($0) }),
|
||||||
|
!hasPlaceSheet,
|
||||||
|
!hasLocate
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if lowercased.contains("calibrat")
|
||||||
|
|| lowercased.contains("white reference")
|
||||||
|
|| lowercased.contains("white tile")
|
||||||
|
|| lowercased.contains("standard tile")
|
||||||
|
|| lowercased.contains("reference")
|
||||||
|
|| lowercased.contains("tile")
|
||||||
|
|| lowercased.contains("hit any key to continue")
|
||||||
|
|| lowercased.contains("hit space to continue") {
|
||||||
|
return ChartreadClassifyResult(state: .calibrating)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Awaiting strip.
|
||||||
|
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
|
||||||
|
// These are explicit, multi-word prompts; we deliberately do NOT
|
||||||
|
// match bare "read strip" so that error lines like
|
||||||
|
// "failed to read strip" or "error reading strip" fall through to
|
||||||
|
// the error matcher.
|
||||||
|
let phrases = [
|
||||||
|
"ready to read",
|
||||||
|
"hit any key to read",
|
||||||
|
"hit a key to read",
|
||||||
|
"hit space to read",
|
||||||
|
"hit [space] to read",
|
||||||
|
"press any key to read",
|
||||||
|
"press space to read",
|
||||||
|
"trigger instrument",
|
||||||
|
"start reading",
|
||||||
|
"read next strip"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Also permit "hit X to read strip Y" or "ready to read strip Z".
|
||||||
|
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Reading.
|
||||||
|
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .reading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Error.
|
||||||
|
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text.lowercased()
|
||||||
|
|
||||||
|
// Avoid false positives from confirmation prompts and "no error" status.
|
||||||
|
guard !lower.contains("no error") else { return nil }
|
||||||
|
guard !lower.contains("(y/n)")
|
||||||
|
&& !lower.contains("'y' or 'n'")
|
||||||
|
&& !lower.contains("?")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
|
||||||
|
for phrase in phraseMatches {
|
||||||
|
if lower.contains(phrase) {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-word "error" only — bare "failed" alone is not enough.
|
||||||
|
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func firstMatch(pattern: String) -> (String, Int, Int)? {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
|
||||||
|
let r = match.range(at: i)
|
||||||
|
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
|
||||||
|
return String(self[range])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let first = groups.first else { return nil }
|
||||||
|
let ints = groups.compactMap { Int($0) }
|
||||||
|
let a = ints.count > 0 ? ints[0] : 0
|
||||||
|
let b = ints.count > 1 ? ints[1] : 0
|
||||||
|
return (first, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `ChartreadArgs` validation.
|
||||||
|
public enum ChartreadArgError: LocalizedError, Equatable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPort(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid chart basename: \(name)"
|
||||||
|
case .invalidPort(let port):
|
||||||
|
return "Invalid chartread port: \(port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a `chartread` invocation.
|
||||||
|
public struct ChartreadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1).
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false
|
||||||
|
) {
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current config implies an XY-table workflow.
|
||||||
|
/// This is normally supplied by the view model from the selected instrument.
|
||||||
|
public var isXY: Bool = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single patch read by `chartread`.
|
||||||
|
public struct ChartreadPatch: Codable, Sendable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let loc: String
|
||||||
|
public let isPad: Bool
|
||||||
|
public let device: [Double]
|
||||||
|
public let expected: PatchColor?
|
||||||
|
public let measured: PatchColor
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
loc: String,
|
||||||
|
isPad: Bool,
|
||||||
|
device: [Double],
|
||||||
|
expected: PatchColor?,
|
||||||
|
measured: PatchColor
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.isPad = isPad
|
||||||
|
self.device = device
|
||||||
|
self.expected = expected
|
||||||
|
self.measured = measured
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, loc
|
||||||
|
case isPad = "is_pad"
|
||||||
|
case device, expected, measured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour payload carried by `expected` or `measured`.
|
||||||
|
public struct PatchColor: Codable, Sendable, Equatable {
|
||||||
|
public let xyz: CIEXYZ?
|
||||||
|
public let lab: CIELab?
|
||||||
|
public let spectral: SpectralData?
|
||||||
|
|
||||||
|
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
|
||||||
|
self.xyz = xyz
|
||||||
|
self.lab = lab
|
||||||
|
self.spectral = spectral
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case xyz = "XYZ"
|
||||||
|
case lab = "Lab"
|
||||||
|
case spectral = "spectral"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
public let endNM: Double
|
||||||
|
public let norm: Double
|
||||||
|
public let values: [Double]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case bands
|
||||||
|
case startNM = "start_nm"
|
||||||
|
case endNM = "end_nm"
|
||||||
|
case norm
|
||||||
|
case values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete row emitted by `chartread -u`.
|
||||||
|
public struct ChartreadRow: Codable, Sendable, Equatable {
|
||||||
|
public let event: String
|
||||||
|
public let rowId: String
|
||||||
|
public let rowIndex: Int
|
||||||
|
public let totalRows: Int
|
||||||
|
public let patchCount: Int
|
||||||
|
public let patches: [ChartreadPatch]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case event
|
||||||
|
case rowId = "row_id"
|
||||||
|
case rowIndex = "row_index"
|
||||||
|
case totalRows = "total_rows"
|
||||||
|
case patchCount = "patch_count"
|
||||||
|
case patches
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isFinalRow: Bool {
|
||||||
|
rowIndex + 1 >= totalRows
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of evaluating one measured patch.
|
||||||
|
public struct SwatchEvaluation: Sendable, Equatable {
|
||||||
|
public let intended: DisplayRGB
|
||||||
|
public let measured: DisplayRGB
|
||||||
|
public let deltaE: Double?
|
||||||
|
public let classification: SwatchClassification
|
||||||
|
|
||||||
|
public init(
|
||||||
|
intended: DisplayRGB,
|
||||||
|
measured: DisplayRGB,
|
||||||
|
deltaE: Double?,
|
||||||
|
classification: SwatchClassification
|
||||||
|
) {
|
||||||
|
self.intended = intended
|
||||||
|
self.measured = measured
|
||||||
|
self.deltaE = deltaE
|
||||||
|
self.classification = classification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
|
||||||
|
case good
|
||||||
|
case warning
|
||||||
|
case bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIEDE2000 ΔE₀₀ between two D50 Lab values.
|
||||||
|
public enum ColorDifference {
|
||||||
|
|
||||||
|
/// Compute ΔE₀₀ using the full CIEDE2000 formula.
|
||||||
|
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
|
||||||
|
let kL: Double = 1
|
||||||
|
let kC: Double = 1
|
||||||
|
let kH: Double = 1
|
||||||
|
|
||||||
|
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
|
||||||
|
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let cBar = (c1 + c2) / 2.0
|
||||||
|
let cBar7 = pow(cBar, 7)
|
||||||
|
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
|
||||||
|
|
||||||
|
let a1p = (1 + g) * lab1.a
|
||||||
|
let a2p = (1 + g) * lab2.a
|
||||||
|
|
||||||
|
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
|
||||||
|
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let h1p = atan2ToDegrees(lab1.b, a1p)
|
||||||
|
let h2p = atan2ToDegrees(lab2.b, a2p)
|
||||||
|
|
||||||
|
let deltaLp = lab2.l - lab1.l
|
||||||
|
let deltaCp = c2p - c1p
|
||||||
|
|
||||||
|
var deltaHp: Double = 0
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
deltaHp = 0
|
||||||
|
} else {
|
||||||
|
let diff = h2p - h1p
|
||||||
|
if abs(diff) <= 180 {
|
||||||
|
deltaHp = diff
|
||||||
|
} else if diff > 180 {
|
||||||
|
deltaHp = diff - 360
|
||||||
|
} else {
|
||||||
|
deltaHp = diff + 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
|
||||||
|
|
||||||
|
let lBarp = (lab1.l + lab2.l) / 2.0
|
||||||
|
let cBarp = (c1p + c2p) / 2.0
|
||||||
|
|
||||||
|
var hBarp: Double
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
hBarp = h1p + h2p
|
||||||
|
} else {
|
||||||
|
if abs(h1p - h2p) <= 180 {
|
||||||
|
hBarp = (h1p + h2p) / 2.0
|
||||||
|
} else if h1p + h2p < 360 {
|
||||||
|
hBarp = (h1p + h2p + 360) / 2.0
|
||||||
|
} else {
|
||||||
|
hBarp = (h1p + h2p - 360) / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = 1
|
||||||
|
- 0.17 * cos(deg2rad(hBarp - 30))
|
||||||
|
+ 0.24 * cos(deg2rad(2 * hBarp))
|
||||||
|
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
|
||||||
|
- 0.20 * cos(deg2rad(4 * hBarp - 63))
|
||||||
|
|
||||||
|
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
|
||||||
|
let cBarp7 = pow(cBarp, 7)
|
||||||
|
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
|
||||||
|
|
||||||
|
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
|
||||||
|
let sc = 1 + 0.045 * cBarp
|
||||||
|
let sh = 1 + 0.015 * cBarp * t
|
||||||
|
|
||||||
|
let rt = -sin(deg2rad(2 * dTheta)) * rc
|
||||||
|
|
||||||
|
let lTerm = deltaLp / (kL * sl)
|
||||||
|
let cTerm = deltaCp / (kC * sc)
|
||||||
|
let hTerm = deltaHp2 / (kH * sh)
|
||||||
|
|
||||||
|
return sqrt(
|
||||||
|
lTerm * lTerm
|
||||||
|
+ cTerm * cTerm
|
||||||
|
+ hTerm * hTerm
|
||||||
|
+ rt * cTerm * hTerm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a ΔE value against user thresholds.
|
||||||
|
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
|
||||||
|
if deltaE < goodMax { return .good }
|
||||||
|
if deltaE < warningMax { return .warning }
|
||||||
|
return .bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
|
||||||
|
public static func evaluate(
|
||||||
|
patch: ChartreadPatch,
|
||||||
|
goodMax: Double,
|
||||||
|
warningMax: Double
|
||||||
|
) -> SwatchEvaluation? {
|
||||||
|
guard let measured = resolveLab(patch.measured) else { return nil }
|
||||||
|
|
||||||
|
let measuredRGB = LabColorMath.labToSRGB(measured)
|
||||||
|
|
||||||
|
if let expectedColor = patch.expected,
|
||||||
|
let expectedLab = resolveLab(expectedColor) {
|
||||||
|
let de = deltaE00(expectedLab, measured)
|
||||||
|
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: intendedRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: de,
|
||||||
|
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No reference: still render measured colour, no ΔE.
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: measuredRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: nil,
|
||||||
|
classification: .good
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||||
|
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||||
|
if let lab = color.lab {
|
||||||
|
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
||||||
|
}
|
||||||
|
guard let xyz = color.xyz else { return nil }
|
||||||
|
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||||
|
let radians = atan2(y, x)
|
||||||
|
var degrees = radians * 180.0 / .pi
|
||||||
|
if degrees < 0 { degrees += 360 }
|
||||||
|
return degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deg2rad(_ degrees: Double) -> Double {
|
||||||
|
degrees * .pi / 180.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A device discovered by the Argyll `instlist` fork.
|
||||||
|
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public let port: Int
|
||||||
|
public let name: String
|
||||||
|
public let type: String
|
||||||
|
|
||||||
|
public init(port: Int, name: String, type: String) {
|
||||||
|
self.port = port
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: Int { port }
|
||||||
|
|
||||||
|
/// XY tables are identified by name or type matching the fork pattern.
|
||||||
|
public var isXY: Bool {
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
let pattern = #"/spectro\s?scan|i1io/"#
|
||||||
|
return combined.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label shown in the picker.
|
||||||
|
public var displayName: String {
|
||||||
|
let xyTag = isXY ? " · XY Table" : ""
|
||||||
|
return "\(name) [\(type)]\(xyTag)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user’s choice for a chartread session.
|
||||||
|
public enum InstrumentSelection: Sendable, Equatable {
|
||||||
|
/// Auto / first available port — `chartread` omits `-c`.
|
||||||
|
case auto
|
||||||
|
/// A concrete instrument.
|
||||||
|
case device(InstrumentDevice)
|
||||||
|
|
||||||
|
/// The value to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
|
||||||
|
public var chartreadPort: Int? {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return nil
|
||||||
|
case .device(let device):
|
||||||
|
return device.port == 1 ? nil : device.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current selection implies an XY table workflow.
|
||||||
|
public var isXY: Bool {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return false
|
||||||
|
case .device(let device):
|
||||||
|
return device.isXY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `instlist` output parsing.
|
||||||
|
public enum InstrumentParserError: Error, Sendable, Equatable {
|
||||||
|
case malformedJSON
|
||||||
|
case missingDevices
|
||||||
|
case invalidPort
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the Argyll `instlist` stdout document.
|
||||||
|
///
|
||||||
|
/// Fork `instlist` emits pretty-printed JSON with the shape
|
||||||
|
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
|
||||||
|
/// If JSON decoding fails, a constrained regex fallback is used.
|
||||||
|
/// Only lines accepted by the fallback must also match known instrument tokens.
|
||||||
|
public enum InstrumentParser {
|
||||||
|
|
||||||
|
/// Known instrument tokens used by the regex fallback.
|
||||||
|
public static let knownInstrumentPattern =
|
||||||
|
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
|
||||||
|
|
||||||
|
/// Parse the complete `instlist` output.
|
||||||
|
public static func parse(_ output: String) throws -> [InstrumentDevice] {
|
||||||
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
|
||||||
|
if let data = trimmed.data(using: .utf8),
|
||||||
|
let decoded = try? decodeJSON(data) {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
|
||||||
|
options: [.caseInsensitive, .anchorsMatchLines]
|
||||||
|
)
|
||||||
|
var devices: [InstrumentDevice] = []
|
||||||
|
let range = NSRange(trimmed.startIndex..., in: trimmed)
|
||||||
|
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let portString = substring(trimmed, range: match.range(at: 1)),
|
||||||
|
let port = Int(portString), port > 0 else { continue }
|
||||||
|
|
||||||
|
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
guard combined.range(of: knownInstrumentPattern,
|
||||||
|
options: [.regularExpression, .caseInsensitive]) != nil,
|
||||||
|
!name.isEmpty else { continue }
|
||||||
|
|
||||||
|
devices.append(InstrumentDevice(port: port, name: name, type: type))
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
|
||||||
|
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
|
||||||
|
return output.devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func substring(_ source: String, range: NSRange) -> String? {
|
||||||
|
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
|
||||||
|
return String(source[r])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstlistOutput: Decodable {
|
||||||
|
let event: String
|
||||||
|
let devices: [InstrumentDevice]
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||||
|
public struct XYZColor: Sendable, Equatable {
|
||||||
|
public let x: Double
|
||||||
|
public let y: Double
|
||||||
|
public let z: Double
|
||||||
|
|
||||||
|
public init(x: Double, y: Double, z: Double) {
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.z = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIELab value (D50).
|
||||||
|
public struct LabColor: Sendable, Equatable {
|
||||||
|
public let l: Double
|
||||||
|
public let a: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(l: Double, a: Double, b: Double) {
|
||||||
|
self.l = l
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB colour in 0–1 display space.
|
||||||
|
public struct DisplayRGB: Sendable, Equatable {
|
||||||
|
public let r: Double
|
||||||
|
public let g: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(r: Double, g: Double, b: Double) {
|
||||||
|
self.r = r
|
||||||
|
self.g = g
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
public var clamped: DisplayRGB {
|
||||||
|
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour-space conversions used by the swatch grid.
|
||||||
|
///
|
||||||
|
/// All numeric paths are deterministic and avoid platform colour-management APIs.
|
||||||
|
public enum LabColorMath {
|
||||||
|
|
||||||
|
// Reference white for D50 (0–100 scale).
|
||||||
|
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
|
||||||
|
|
||||||
|
// Reference white for D65 (0–100 scale).
|
||||||
|
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
|
||||||
|
|
||||||
|
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
|
||||||
|
static let bradford = [
|
||||||
|
[ 0.8951, 0.2664, -0.1614],
|
||||||
|
[-0.7502, 1.7135, 0.0367],
|
||||||
|
[ 0.0389, -0.0685, 1.0296]
|
||||||
|
]
|
||||||
|
|
||||||
|
static let bradfordInv = [
|
||||||
|
[ 0.9869929, -0.1470543, 0.1599627],
|
||||||
|
[ 0.4323053, 0.5183603, 0.0492912],
|
||||||
|
[-0.0085287, 0.0400428, 0.9684866]
|
||||||
|
]
|
||||||
|
|
||||||
|
// sRGB D65 matrix (XYZ -> linear sRGB, using 0–100 inputs).
|
||||||
|
static let srgbMatrix = [
|
||||||
|
[ 3.2406, -1.5372, -0.4986],
|
||||||
|
[-0.9689, 1.8758, 0.0415],
|
||||||
|
[ 0.0557, -0.2040, 1.0570]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Convert XYZ (0–100) to CIELab D50.
|
||||||
|
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
|
||||||
|
let f: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta * delta * delta {
|
||||||
|
return pow(t, 1.0 / 3.0)
|
||||||
|
} else {
|
||||||
|
return t / (3 * delta * delta) + 4.0 / 29.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = f(xyz.x / d50White.X)
|
||||||
|
let y = f(xyz.y / d50White.Y)
|
||||||
|
let zr = f(xyz.z / d50White.Z)
|
||||||
|
|
||||||
|
return LabColor(
|
||||||
|
l: 116.0 * y - 16.0,
|
||||||
|
a: 500.0 * (x - y),
|
||||||
|
b: 200.0 * (y - zr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert CIELab D50 to XYZ (0–100).
|
||||||
|
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
|
||||||
|
let finv: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta {
|
||||||
|
return t * t * t
|
||||||
|
} else {
|
||||||
|
return 3 * delta * delta * (t - 4.0 / 29.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let yr = (lab.l + 16.0) / 116.0
|
||||||
|
let xr = yr + lab.a / 500.0
|
||||||
|
let zr = yr - lab.b / 200.0
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: finv(xr) * d50White.X,
|
||||||
|
y: finv(yr) * d50White.Y,
|
||||||
|
z: finv(zr) * d50White.Z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
|
||||||
|
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
|
||||||
|
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
|
||||||
|
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
|
||||||
|
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
|
||||||
|
|
||||||
|
let scaled = [
|
||||||
|
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||||
|
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||||
|
source[2] * (dstWhite[2] / srcWhite[2])
|
||||||
|
]
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
|
||||||
|
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
|
||||||
|
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D65 (0–100) to linear sRGB, apply gamma, and clamp.
|
||||||
|
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
|
||||||
|
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
|
||||||
|
// The input is 0–100, so scale by 100 first.
|
||||||
|
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
|
||||||
|
let linear = matrixMultiply(srgbMatrix, scaled)
|
||||||
|
|
||||||
|
func gamma(_ c: Double) -> Double {
|
||||||
|
if c <= 0.0031308 {
|
||||||
|
return 12.92 * c
|
||||||
|
} else {
|
||||||
|
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DisplayRGB(
|
||||||
|
r: gamma(linear[0]),
|
||||||
|
g: gamma(linear[1]),
|
||||||
|
b: gamma(linear[2])
|
||||||
|
).clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete D50 Lab -> display sRGB conversion.
|
||||||
|
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
|
||||||
|
let xyz50 = labToXYZ(lab)
|
||||||
|
let xyz65 = adaptD50ToD65(xyz50)
|
||||||
|
return xyzToSRGB(xyz65)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an Argyll XYZ array (0–100) to Lab and then to sRGB.
|
||||||
|
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
|
||||||
|
guard xyz.count >= 3 else { return nil }
|
||||||
|
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||||
|
var result = [Double](repeating: 0, count: m.count)
|
||||||
|
for i in 0..<m.count {
|
||||||
|
for j in 0..<v.count {
|
||||||
|
result[i] += m[i][j] * v[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from pass-snapshot, promote, and discovery operations.
|
||||||
|
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case canonicalMissing(URL)
|
||||||
|
case snapshotFailed(String)
|
||||||
|
case promoteFailed(String)
|
||||||
|
case noPassFiles
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid measurement basename: \(name)"
|
||||||
|
case .canonicalMissing(let url):
|
||||||
|
return "Canonical .ti3 not found: \(url.path)"
|
||||||
|
case .snapshotFailed(let reason):
|
||||||
|
return "Snapshot failed: \(reason)"
|
||||||
|
case .promoteFailed(let reason):
|
||||||
|
return "Promote failed: \(reason)"
|
||||||
|
case .noPassFiles:
|
||||||
|
return "No pass .ti3 snapshots are available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem helpers for multi-pass measurement artefacts.
|
||||||
|
///
|
||||||
|
/// Pass snapshots use 1-based numbering and are tracked as
|
||||||
|
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
|
||||||
|
/// Finish / Average (docs/24 #109, #110).
|
||||||
|
public enum MeasurementArtefacts {
|
||||||
|
|
||||||
|
/// Find all existing pass snapshots in `cwd`, sorted numerically.
|
||||||
|
public static func passSnapshots(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let suffix = "ti3"
|
||||||
|
|
||||||
|
let passes: [(Int, URL)] = entries.compactMap { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
guard url.pathExtension.lowercased() == suffix,
|
||||||
|
name.hasPrefix(prefix)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
guard let number = Int(numberPart), number > 0 else { return nil }
|
||||||
|
return (number, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return passes
|
||||||
|
.sorted { $0.0 < $1.0 }
|
||||||
|
.map { $0.1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next 1-based pass number.
|
||||||
|
public static func nextPassNumber(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Int {
|
||||||
|
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
|
||||||
|
guard let last = existing.last else { return 1 }
|
||||||
|
let name = last.lastPathComponent
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
return (Int(numberPart) ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
|
||||||
|
///
|
||||||
|
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
|
||||||
|
public static func snapshotPass(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
guard fileManager.fileExists(atPath: canonical.path) else {
|
||||||
|
throw MeasurementArtefactError.canonicalMissing(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
|
||||||
|
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: canonical, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: pass.path) {
|
||||||
|
try? fileManager.removeItem(at: pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: pass)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: canonical)
|
||||||
|
} catch {
|
||||||
|
// The pass file is durable; canonical removal failure is logged but not fatal.
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
|
||||||
|
public static func promotePass(
|
||||||
|
pass: URL,
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: pass.path) else {
|
||||||
|
throw MeasurementArtefactError.noPassFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: pass, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: canonical.path) {
|
||||||
|
try? fileManager.removeItem(at: canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: canonical)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical `<basename>.ti3` URL, regardless of existence.
|
||||||
|
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
return cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,20 +14,41 @@ public enum AppPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `~/Library/Application Support/com.gronod.iccery2`
|
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||||
|
///
|
||||||
|
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
||||||
|
/// against an isolated root and never touch the developer's state.
|
||||||
public static var appDataDir: URL {
|
public static var appDataDir: URL {
|
||||||
FileManager.default
|
#if DEBUG
|
||||||
|
if let root = testRoot {
|
||||||
|
return root.appendingPathComponent("AppData", isDirectory: true)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return FileManager.default
|
||||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `~/Library/Logs/com.gronod.iccery2`
|
/// `~/Library/Logs/com.gronod.iccery2`
|
||||||
public static var logDir: URL {
|
public static var logDir: URL {
|
||||||
FileManager.default
|
#if DEBUG
|
||||||
|
if let root = testRoot {
|
||||||
|
return root.appendingPathComponent("Logs", isDirectory: true)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return FileManager.default
|
||||||
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||||
.appendingPathComponent("Logs", isDirectory: true)
|
.appendingPathComponent("Logs", isDirectory: true)
|
||||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private static var testRoot: URL? {
|
||||||
|
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
|
||||||
|
!raw.isEmpty else { return nil }
|
||||||
|
return URL(fileURLWithPath: raw, isDirectory: true)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
|
||||||
public static var logFile: URL {
|
public static var logFile: URL {
|
||||||
logDir.appendingPathComponent("iccery.log", isDirectory: false)
|
logDir.appendingPathComponent("iccery.log", isDirectory: false)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ordered private-SPI attempt table for the ColorSync suppression
|
||||||
|
/// engine (issue 14 layer ②, docs/11).
|
||||||
|
///
|
||||||
|
/// The ordering is data so the exact dlsym/mode sequence is unit-
|
||||||
|
/// testable without resolving any private symbols. The app layer walks
|
||||||
|
/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,…)`, and
|
||||||
|
/// calls the first `(symbol, mode)` that returns `0` — verified on
|
||||||
|
/// macOS 14+ that all three symbols exist.
|
||||||
|
///
|
||||||
|
/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||||
|
/// The second argument is the **mode string**, never integer `1`
|
||||||
|
/// (#188 — a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and
|
||||||
|
/// `AP_VendorColorMatching` are forbidden modes — they re-enable
|
||||||
|
/// ColorSync/driver colour management.
|
||||||
|
public enum ColorMatchingAttempts {
|
||||||
|
|
||||||
|
/// dlsym order: `…Lock` first (holds the print-session lock while
|
||||||
|
/// setting), then the plain setter, then `…NoLock`.
|
||||||
|
public static let symbols: [String] = [
|
||||||
|
"PMSessionSetColorMatchingModeLock",
|
||||||
|
"PMSessionSetColorMatchingMode",
|
||||||
|
"PMSessionSetColorMatchingModeNoLock",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Mode strings tried per symbol, in order. `AP_…` is the
|
||||||
|
/// documented mode; the unprefixed variant is the older alias.
|
||||||
|
public static let modes: [String] = [
|
||||||
|
"AP_ApplicationColorMatching",
|
||||||
|
"ApplicationColorMatching",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Symbol-outer, mode-inner — the full attempt sequence; the app
|
||||||
|
/// stops at the first call that returns `0`.
|
||||||
|
public static var attempts: [(symbol: String, mode: String)] {
|
||||||
|
symbols.flatMap { symbol in
|
||||||
|
modes.map { (symbol: symbol, mode: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layer ③: both spellings of the print-settings key are written
|
||||||
|
/// with `locked = true`. Written as `CFString` values.
|
||||||
|
public static let applicationMatchingValue = "AP_ApplicationColorMatching"
|
||||||
|
public static let printSettingsKeys: [String] = [
|
||||||
|
"AP_ColorMatchingMode",
|
||||||
|
"AP.ColorMatchingMode",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CUPS option filtering for `PMPrintSettingsToOptions` capture
|
||||||
|
/// (issue 14 layer ⑥, docs/11 §filter).
|
||||||
|
///
|
||||||
|
/// The captured `key=value` string is reduced to the options that
|
||||||
|
/// should be replayed on `lp`: `com.apple.*` ticket keys, job
|
||||||
|
/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`,
|
||||||
|
/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode`
|
||||||
|
/// keys are dropped — `build_lp_args` always re-adds those itself
|
||||||
|
/// (issue 15). Unknown non-`com.*` keys are kept (permissive — vendor
|
||||||
|
/// driver keys survive).
|
||||||
|
public enum CupsOptionsFilter {
|
||||||
|
|
||||||
|
/// Option keys forwarded from the panel to `lp` (docs/11 roster).
|
||||||
|
public static let relevantKeys: Set<String> = [
|
||||||
|
// Media
|
||||||
|
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
|
||||||
|
// Tray
|
||||||
|
"InputSlot", "AP_D_InputSlot",
|
||||||
|
// Size
|
||||||
|
"PageSize",
|
||||||
|
// Colour bypass
|
||||||
|
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
|
||||||
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
|
"EpsonColorMode", "ColorModel",
|
||||||
|
// Quality
|
||||||
|
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
||||||
|
"CNIJQuality", "StpQuality", "OutputMode",
|
||||||
|
// Duplex
|
||||||
|
"Duplex", "sides",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Keys we always drop regardless of the relevant list. `raw` is
|
||||||
|
/// included — a captured `raw=…` would re-enable CUPS raw mode and
|
||||||
|
/// bypass the raster filter that honours `AP_ApplicationColorMatching`
|
||||||
|
/// (#92).
|
||||||
|
public static let alwaysDropped: Set<String> = [
|
||||||
|
"collate", "copies", "pserrorhandler-requested", "job-sheets",
|
||||||
|
"AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// A `key=value` pair survives when the key is non-empty, the value
|
||||||
|
/// is non-empty, the key is not `com.apple.*`, not always-dropped,
|
||||||
|
/// and either relevant or an unknown non-`com.*` driver key.
|
||||||
|
public static func isRelevant(key: String, value: String) -> Bool {
|
||||||
|
guard !key.isEmpty, !value.isEmpty else { return false }
|
||||||
|
if key.hasPrefix("com.apple.") { return false }
|
||||||
|
if alwaysDropped.contains(key) { return false }
|
||||||
|
if relevantKeys.contains(key) { return true }
|
||||||
|
// Permissive: unknown vendor keys survive (non-com.*).
|
||||||
|
return !key.hasPrefix("com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `key=value key=value …` → filtered string, order preserved.
|
||||||
|
public static func filter(_ options: String) -> String {
|
||||||
|
CupsParsers.lpoptions(options)
|
||||||
|
.filter { isRelevant(key: $0.key, value: $0.value) }
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`.
|
||||||
|
public struct CupsOptionListing: Equatable, Sendable {
|
||||||
|
/// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`.
|
||||||
|
public var key: String
|
||||||
|
/// Human label after `/`, e.g. `Media Source`.
|
||||||
|
public var label: String
|
||||||
|
/// All choices, `*` stripped.
|
||||||
|
public var choices: [String]
|
||||||
|
/// The `*`-prefixed default choice, if any.
|
||||||
|
public var defaultChoice: String?
|
||||||
|
|
||||||
|
public init(key: String, label: String, choices: [String], defaultChoice: String?) {
|
||||||
|
self.key = key
|
||||||
|
self.label = label
|
||||||
|
self.choices = choices
|
||||||
|
self.defaultChoice = defaultChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12,
|
||||||
|
/// docs/10–11). Recorded fixtures drive the tests — no live CUPS.
|
||||||
|
public enum CupsParsers {
|
||||||
|
|
||||||
|
// MARK: - lpstat
|
||||||
|
|
||||||
|
/// `lpstat -e` — one CUPS destination name per line.
|
||||||
|
public static func lpstatDestinations(_ output: String) -> [String] {
|
||||||
|
output.split(separator: "\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lpstat -p` — `printer NAME is idle. enabled since …`,
|
||||||
|
/// `printer NAME now printing NAME-1. …`, `printer NAME disabled
|
||||||
|
/// since …` → queue → status.
|
||||||
|
public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] {
|
||||||
|
var result: [String: PrinterStatus] = [:]
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.hasPrefix("printer ") else { continue }
|
||||||
|
let rest = text.dropFirst("printer ".count)
|
||||||
|
guard let sep = rest.firstIndex(of: " ") else { continue }
|
||||||
|
let name = String(rest[..<sep])
|
||||||
|
let desc = rest[sep...].lowercased()
|
||||||
|
if desc.contains("now printing") {
|
||||||
|
result[name] = .printing
|
||||||
|
} else if desc.contains("idle") {
|
||||||
|
result[name] = .idle
|
||||||
|
} else if desc.contains("disabled") || desc.contains("stopped") {
|
||||||
|
result[name] = .stopped
|
||||||
|
} else {
|
||||||
|
result[name] = .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lpstat -d` — `system default destination: NAME`, or
|
||||||
|
/// `no system default destination` → nil.
|
||||||
|
public static func lpstatDefault(_ output: String) -> String? {
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let colon = text.firstIndex(of: ":") else { continue }
|
||||||
|
let name = text[text.index(after: colon)...]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if text.lowercased().hasPrefix("system default destination"),
|
||||||
|
!name.isEmpty {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - lpoptions -p <queue>
|
||||||
|
|
||||||
|
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||||
|
/// single-quoted (`printer-info='EPSON XP-55 Series'`); bare
|
||||||
|
/// flags (`printer-location`) parse as present-with-empty-value.
|
||||||
|
public static func lpoptions(_ output: String) -> [(key: String, value: String)] {
|
||||||
|
var pairs: [(String, String)] = []
|
||||||
|
var index = output.startIndex
|
||||||
|
while index < output.endIndex {
|
||||||
|
while index < output.endIndex && output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
guard index < output.endIndex else { break }
|
||||||
|
let tokenStart = index
|
||||||
|
while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
let key = String(output[tokenStart..<index])
|
||||||
|
// A token starting with `=` has no key — skip it (and its
|
||||||
|
// value) rather than truncating the whole parse.
|
||||||
|
guard !key.isEmpty else {
|
||||||
|
if index < output.endIndex && output[index] == "=" {
|
||||||
|
index = output.index(after: index)
|
||||||
|
while index < output.endIndex
|
||||||
|
&& !output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if index < output.endIndex && output[index] == "=" {
|
||||||
|
index = output.index(after: index)
|
||||||
|
if index < output.endIndex && output[index] == "'" {
|
||||||
|
// Single-quoted value — scan to closing quote.
|
||||||
|
index = output.index(after: index)
|
||||||
|
let valueStart = index
|
||||||
|
while index < output.endIndex && output[index] != "'" {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
pairs.append((key, String(output[valueStart..<index])))
|
||||||
|
if index < output.endIndex { index = output.index(after: index) }
|
||||||
|
} else {
|
||||||
|
let valueStart = index
|
||||||
|
while index < output.endIndex && !output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
pairs.append((key, String(output[valueStart..<index])))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pairs.append((key, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name from `printer-info` in `lpoptions -p` output.
|
||||||
|
public static func lpoptionsDisplayName(_ output: String) -> String? {
|
||||||
|
guard let value = lpoptions(output)
|
||||||
|
.first(where: { $0.key == "printer-info" })?.value,
|
||||||
|
!value.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - lpoptions -l
|
||||||
|
|
||||||
|
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||||
|
/// A missing `/` label reuses the key.
|
||||||
|
public static func lpoptionsList(_ output: String) -> [CupsOptionListing] {
|
||||||
|
output.split(separator: "\n").compactMap { raw in
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let colon = line.firstIndex(of: ":") else { return nil }
|
||||||
|
let head = String(line[..<colon])
|
||||||
|
let body = line[line.index(after: colon)...]
|
||||||
|
let headParts = head.split(separator: "/", maxSplits: 1)
|
||||||
|
let key = headParts[0].trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !key.isEmpty else { return nil }
|
||||||
|
let label = headParts.count > 1
|
||||||
|
? headParts[1].trimmingCharacters(in: .whitespaces)
|
||||||
|
: key
|
||||||
|
var choices: [String] = []
|
||||||
|
var defaultChoice: String?
|
||||||
|
for token in body.split(separator: " ") {
|
||||||
|
if token.hasPrefix("*") {
|
||||||
|
let value = String(token.dropFirst())
|
||||||
|
defaultChoice = value
|
||||||
|
choices.append(value)
|
||||||
|
} else {
|
||||||
|
choices.append(String(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CupsOptionListing(
|
||||||
|
key: key, label: label,
|
||||||
|
choices: choices, defaultChoice: defaultChoice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PPD enrichment
|
||||||
|
|
||||||
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
|
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||||
|
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||||
|
var map: [String: String] = [:]
|
||||||
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
|
line = String(line.dropFirst())
|
||||||
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
|
// Only strip when the part before the first `.` looks like
|
||||||
|
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
||||||
|
// are left alone.
|
||||||
|
if let dot = line.firstIndex(of: ".") {
|
||||||
|
let prefix = line[..<dot]
|
||||||
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
|
&& prefix.allSatisfy { $0.isLetter || $0 == "_" }
|
||||||
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
|
let candidate = line[line.index(after: dot)...]
|
||||||
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
line = String(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard line.hasPrefix(key) else { continue }
|
||||||
|
var rest = line[line.index(line.startIndex, offsetBy: key.count)...]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||||
|
rest = String(rest[..<colon])
|
||||||
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
|
let id = String(rest[..<slash])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let human = String(rest[rest.index(after: slash)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
|
/// Media-type option key in preference order — used both to read a
|
||||||
|
/// captured value and to emit `-o <key>=<media>`.
|
||||||
|
public static let mediaTypeKeys = [
|
||||||
|
"CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType"
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func detectMediaTypeKey(optionKeys: Set<String>) -> String? {
|
||||||
|
mediaTypeKeys.first { optionKeys.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media type from a captured `key=value key=value` options string.
|
||||||
|
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
||||||
|
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||||
|
let pairs = lpoptions(options)
|
||||||
|
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||||
|
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
||||||
|
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
||||||
|
/// Gutenprint `StpColorCorrection=Uncorrected`; generic
|
||||||
|
/// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`.
|
||||||
|
public static func detectDriverColorBypass(
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) -> (key: String, value: String)? {
|
||||||
|
if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") }
|
||||||
|
if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") }
|
||||||
|
if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") }
|
||||||
|
if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") }
|
||||||
|
if optionKeys.contains("StpColorCorrection") {
|
||||||
|
return ("StpColorCorrection", "Uncorrected")
|
||||||
|
}
|
||||||
|
if optionKeys.contains("ColorCorrection") {
|
||||||
|
return ("ColorCorrection", "Uncorrected")
|
||||||
|
}
|
||||||
|
if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key=value pairs of colour-bypass keys — used to detect
|
||||||
|
/// whether captured options already carry a bypass.
|
||||||
|
public static let bypassKeys: Set<String> = [
|
||||||
|
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
|
||||||
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
|
"EpsonColorMode",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from CUPS tool invocations.
|
||||||
|
public enum CupsError: LocalizedError, Equatable {
|
||||||
|
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||||
|
case tiffMissing(String)
|
||||||
|
case noPrinterSelected
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .toolFailed(let tool, let code, let stderr):
|
||||||
|
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return detail.isEmpty
|
||||||
|
? "\(tool) failed with exit code \(code)"
|
||||||
|
: "\(tool) failed (\(code)): \(detail)"
|
||||||
|
case .tiffMissing(let path):
|
||||||
|
return "Target TIFF does not exist: \(path)"
|
||||||
|
case .noPrinterSelected:
|
||||||
|
return "No printer selected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CUPS command surface (issue 12): enumerates queues and reads
|
||||||
|
/// per-queue capabilities via `/usr/bin/lpstat` and
|
||||||
|
/// `/usr/bin/lpoptions`. Spawning goes through
|
||||||
|
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
|
||||||
|
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
|
||||||
|
/// injectable so tests use fixture scripts and never touch real CUPS.
|
||||||
|
public struct CupsService: Sendable {
|
||||||
|
public let processManager: ProcessManager
|
||||||
|
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
|
||||||
|
/// production, a fixture dir under test.
|
||||||
|
public let binaryDir: URL
|
||||||
|
/// `/etc/cups/ppd` in production.
|
||||||
|
public let ppdDir: URL
|
||||||
|
|
||||||
|
public init(
|
||||||
|
processManager: ProcessManager = .shared,
|
||||||
|
binaryDir: URL = URL(fileURLWithPath: "/usr/bin"),
|
||||||
|
ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd")
|
||||||
|
) {
|
||||||
|
self.processManager = processManager
|
||||||
|
self.binaryDir = binaryDir
|
||||||
|
self.ppdDir = ppdDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enumeration (lpstat -e/-p/-d)
|
||||||
|
|
||||||
|
/// All CUPS destinations with status and default flag. An empty
|
||||||
|
/// list is a valid result, not an error.
|
||||||
|
public func listPrinters() async throws -> [Printer] {
|
||||||
|
// lpstat exits non-zero when no destinations exist — an empty
|
||||||
|
// queue list is a valid result, not a failure (issue 12).
|
||||||
|
let destinationsOut = try await run(
|
||||||
|
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
|
||||||
|
let statusOut = try await run(
|
||||||
|
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
|
||||||
|
let defaultOut = try await run(
|
||||||
|
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
|
||||||
|
|
||||||
|
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||||
|
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||||
|
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||||
|
|
||||||
|
var printers: [Printer] = []
|
||||||
|
for name in names {
|
||||||
|
let displayName = try? await displayName(for: name)
|
||||||
|
printers.append(Printer(
|
||||||
|
name: name,
|
||||||
|
status: statuses[name] ?? .unknown,
|
||||||
|
isDefault: name == defaultName,
|
||||||
|
displayName: displayName
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return printers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||||
|
/// display name, docs/11 §binding).
|
||||||
|
public func displayName(for queue: String) async throws -> String? {
|
||||||
|
let result = try await run(
|
||||||
|
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
|
||||||
|
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||||
|
|
||||||
|
/// Raw `Key/Label: choices` listings for a queue — also the input
|
||||||
|
/// to media-key and colour-bypass detection (docs/11 layer ④).
|
||||||
|
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
|
||||||
|
let result = try await run(
|
||||||
|
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
|
||||||
|
return CupsParsers.lpoptionsList(result.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trays / paper sizes / media types for a queue, with PPD
|
||||||
|
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
||||||
|
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
||||||
|
let listings = try await optionListings(for: queue)
|
||||||
|
return capabilities(from: listings, ppd: loadPPD(for: queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure mapping — extracted so fixture tests need no process.
|
||||||
|
public func capabilities(
|
||||||
|
from listings: [CupsOptionListing],
|
||||||
|
ppd: String?
|
||||||
|
) -> PrinterCapabilities {
|
||||||
|
var trays: [PrinterTray] = []
|
||||||
|
var sizes: [PrinterPaperSize] = []
|
||||||
|
var media: [PrinterMediaType] = []
|
||||||
|
|
||||||
|
for listing in listings {
|
||||||
|
switch listing.key {
|
||||||
|
case "InputSlot", "MediaSource":
|
||||||
|
trays = listing.choices.enumerated().map {
|
||||||
|
PrinterTray(id: $0.offset + 1, name: $0.element)
|
||||||
|
}
|
||||||
|
case "PageSize", "MediaSize":
|
||||||
|
sizes = listing.choices.enumerated().map {
|
||||||
|
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
|
||||||
|
}
|
||||||
|
case let key where CupsParsers.mediaTypeKeys.contains(key):
|
||||||
|
guard media.isEmpty else { continue }
|
||||||
|
let labels = ppd.map {
|
||||||
|
CupsParsers.ppdChoiceLabels($0, key: key)
|
||||||
|
} ?? [:]
|
||||||
|
media = listing.choices.map {
|
||||||
|
PrinterMediaType(id: $0, name: labels[$0] ?? $0)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PrinterCapabilities(
|
||||||
|
trays: trays, paperSizes: sizes, mediaTypes: media)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of option keys a queue advertises — input to
|
||||||
|
/// `detectDriverColorBypass` / `detectMediaTypeKey`.
|
||||||
|
public func optionKeys(for queue: String) async throws -> Set<String> {
|
||||||
|
Set(try await optionListings(for: queue).map(\.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spool (issue 15)
|
||||||
|
|
||||||
|
/// `lp -d <queue> … <tiff>` — spool one target page unmanaged.
|
||||||
|
/// Never uses `-o raw` (#92). `page` disambiguates the process id
|
||||||
|
/// when several pages are spooled in sequence.
|
||||||
|
public func printTarget(
|
||||||
|
queue: String,
|
||||||
|
tiffPath: String,
|
||||||
|
options: PrintOptions,
|
||||||
|
page: Int = 0
|
||||||
|
) async throws {
|
||||||
|
guard FileManager.default.fileExists(atPath: tiffPath) else {
|
||||||
|
throw CupsError.tiffMissing(tiffPath)
|
||||||
|
}
|
||||||
|
let optionKeys = (try? await self.optionKeys(for: queue)) ?? []
|
||||||
|
let argv = try LpArgs.build(
|
||||||
|
queue: queue, tiffPath: tiffPath,
|
||||||
|
options: options, optionKeys: optionKeys)
|
||||||
|
try await run("lp", argv, id: ProcessID.lp(queue, page: page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PPD
|
||||||
|
|
||||||
|
private func loadPPD(for queue: String) -> String? {
|
||||||
|
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||||
|
return try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func run(
|
||||||
|
_ tool: String,
|
||||||
|
_ arguments: [String],
|
||||||
|
id: String,
|
||||||
|
tolerateFailure: Bool = false
|
||||||
|
) async throws -> CapturedResult {
|
||||||
|
let binary = binaryDir.appendingPathComponent(tool)
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: id, binary: binary, arguments: arguments)
|
||||||
|
if result.exitCode != 0, !tolerateFailure {
|
||||||
|
throw CupsError.toolFailed(
|
||||||
|
tool: tool, code: result.exitCode, stderr: result.stderr)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `buildLpArgs`.
|
||||||
|
public enum LpArgsError: LocalizedError, Equatable {
|
||||||
|
case unsanitisedOption(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsanitisedOption(let option):
|
||||||
|
return "Captured CUPS option contains unsafe characters: \(option)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lp` argv builder — issue 15, docs/11 `build_lp_args`.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// lp -d <queue> -t "ICCery Target - <file>"
|
||||||
|
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
/// <captured cups_options>
|
||||||
|
/// <media_type, if no media key already captured>
|
||||||
|
/// <driver bypass, if no bypass key captured>
|
||||||
|
/// <orientation-requested=3|4, unless captured>
|
||||||
|
/// <PageSize, unless captured>
|
||||||
|
/// <tiff>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// - **Never `-o raw`** — `raw` skips the raster filter that honours
|
||||||
|
/// `AP_ApplicationColorMatching` (#92).
|
||||||
|
/// - Captured options **win** over explicit fields: any key already
|
||||||
|
/// present (case-insensitive) suppresses the derived `-o`.
|
||||||
|
/// - Captured keys/values are sanitised — `;`, newlines, or shell
|
||||||
|
/// metacharacters throw `unsanitisedOption`; args are passed as a
|
||||||
|
/// `Process` argv array, never through a shell.
|
||||||
|
/// - The TIFF path is always the **last** argument.
|
||||||
|
public enum LpArgs {
|
||||||
|
|
||||||
|
/// `options` = the captured `PrintOptions`; `optionKeys` = the
|
||||||
|
/// queue's `lpoptions -l` key set (for media-key/bypass detection).
|
||||||
|
public static func build(
|
||||||
|
queue: String,
|
||||||
|
tiffPath: String,
|
||||||
|
options: PrintOptions,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) throws -> [String] {
|
||||||
|
var argv: [String] = [
|
||||||
|
"-d", queue,
|
||||||
|
"-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)",
|
||||||
|
"-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching",
|
||||||
|
"-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching",
|
||||||
|
]
|
||||||
|
var addedKeys: Set<String> = [
|
||||||
|
"ap_colormatchingmode", "ap.colormatchingmode",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Captured CUPS options — sanitised, lowercased-key dedup.
|
||||||
|
if let captured = options.cupsOptions, !captured.isEmpty {
|
||||||
|
// Newlines can't survive the tokeniser — check the raw
|
||||||
|
// string so embedded line breaks are still rejected.
|
||||||
|
if captured.contains("\n") || captured.contains("\r") {
|
||||||
|
throw LpArgsError.unsanitisedOption(captured)
|
||||||
|
}
|
||||||
|
for pair in CupsParsers.lpoptions(captured) {
|
||||||
|
try sanitize(pair.key, pair.value)
|
||||||
|
let lowered = pair.key.lowercased()
|
||||||
|
// Defence in depth: never let a captured `raw` reach
|
||||||
|
// argv — `-o raw` skips the raster filter that honours
|
||||||
|
// AP_ApplicationColorMatching (#92).
|
||||||
|
if lowered == "raw" { continue }
|
||||||
|
guard !addedKeys.contains(lowered) else { continue }
|
||||||
|
addedKeys.insert(lowered)
|
||||||
|
argv += ["-o", "\(pair.key)=\(pair.value)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media type — only when the captured options didn't carry one.
|
||||||
|
if let mediaType = options.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(mediaKey.lowercased()) {
|
||||||
|
addedKeys.insert(mediaKey.lowercased())
|
||||||
|
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver colour bypass — when no bypass key was captured. NOT
|
||||||
|
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||||
|
let capturedKeys = Set(
|
||||||
|
CupsParsers.lpoptions(options.cupsOptions ?? "")
|
||||||
|
.map { $0.key })
|
||||||
|
if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys),
|
||||||
|
let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(bypass.key.lowercased()) {
|
||||||
|
addedKeys.insert(bypass.key.lowercased())
|
||||||
|
argv += ["-o", "\(bypass.key)=\(bypass.value)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orientation — portrait=3, landscape=4.
|
||||||
|
if let orientation = options.orientation,
|
||||||
|
!addedKeys.contains("orientation-requested") {
|
||||||
|
let value = orientation == "landscape" ? "4" : "3"
|
||||||
|
addedKeys.insert("orientation-requested")
|
||||||
|
argv += ["-o", "orientation-requested=\(value)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageSize — the printtarg layout page size.
|
||||||
|
if let paperSize = options.paperSize, !paperSize.isEmpty,
|
||||||
|
!addedKeys.contains("pagesize") {
|
||||||
|
argv += ["-o", "PageSize=\(paperSize)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
argv.append(tiffPath)
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject shell/metachar injection — args go to `Process` as an
|
||||||
|
/// argv array, but a hostile captured string must not smuggle a
|
||||||
|
/// second option or command.
|
||||||
|
static func sanitize(_ key: String, _ value: String) throws {
|
||||||
|
let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'")
|
||||||
|
if key.rangeOfCharacter(from: forbidden) != nil
|
||||||
|
|| value.rangeOfCharacter(from: forbidden) != nil
|
||||||
|
|| key.isEmpty {
|
||||||
|
throw LpArgsError.unsanitisedOption("\(key)=\(value)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Queue status reported by `lpstat -p` (docs/10 §Printer).
|
||||||
|
public enum PrinterStatus: String, Codable, Sendable, CaseIterable {
|
||||||
|
case idle = "Idle"
|
||||||
|
case printing = "Printing"
|
||||||
|
case stopped = "Stopped"
|
||||||
|
case unknown = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CUPS destination. `name` is the queue id sent back to every
|
||||||
|
/// subsequent print command; `displayName` is the human label from
|
||||||
|
/// `printer-info` (used as the `NSPrinter` fallback when PM binding
|
||||||
|
/// fails — #188).
|
||||||
|
public struct Printer: Codable, Equatable, Sendable {
|
||||||
|
public var name: String
|
||||||
|
public var status: PrinterStatus
|
||||||
|
public var isDefault: Bool
|
||||||
|
public var displayName: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
status: PrinterStatus = .unknown,
|
||||||
|
isDefault: Bool = false,
|
||||||
|
displayName: String? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.status = status
|
||||||
|
self.isDefault = isDefault
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paper source. `id` is the 1-based index of the `InputSlot` /
|
||||||
|
/// `MediaSource` choice (not a PPD code) — docs/10.
|
||||||
|
public struct PrinterTray: Codable, Equatable, Sendable {
|
||||||
|
public var id: Int
|
||||||
|
public var name: String
|
||||||
|
|
||||||
|
public init(id: Int, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media size from `PageSize` / `MediaSize` choices (1-based index).
|
||||||
|
public struct PrinterPaperSize: Codable, Equatable, Sendable {
|
||||||
|
public var id: Int
|
||||||
|
public var name: String
|
||||||
|
|
||||||
|
public init(id: Int, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media type: `id` is the PPD machine token, `name` the human label
|
||||||
|
/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human).
|
||||||
|
public struct PrinterMediaType: Codable, Equatable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
|
||||||
|
public init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||||
|
public var trays: [PrinterTray]
|
||||||
|
public var paperSizes: [PrinterPaperSize]
|
||||||
|
public var mediaTypes: [PrinterMediaType]
|
||||||
|
/// Always `true` on macOS (spec parity — CUPS honours
|
||||||
|
/// `orientation-requested`).
|
||||||
|
public var supportsOrientation: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
trays: [PrinterTray] = [],
|
||||||
|
paperSizes: [PrinterPaperSize] = [],
|
||||||
|
mediaTypes: [PrinterMediaType] = [],
|
||||||
|
supportsOrientation: Bool = true
|
||||||
|
) {
|
||||||
|
self.trays = trays
|
||||||
|
self.paperSizes = paperSizes
|
||||||
|
self.mediaTypes = mediaTypes
|
||||||
|
self.supportsOrientation = supportsOrientation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
|
||||||
|
/// `paperSource` is ignored unless already present inside captured
|
||||||
|
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
|
||||||
|
/// it on OK) but never gates the argv — macOS always bypasses driver
|
||||||
|
/// colour management.
|
||||||
|
public struct PrintOptions: Codable, Equatable, Sendable {
|
||||||
|
public var paperSource: Int?
|
||||||
|
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
|
||||||
|
public var orientation: String?
|
||||||
|
/// printtarg layout page size → `PageSize=` (skipped if captured).
|
||||||
|
public var paperSize: String?
|
||||||
|
public var mediaType: String?
|
||||||
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
|
/// Space-separated `key=value` captured from
|
||||||
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||||
|
public var cupsOptions: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
paperSource: Int? = nil,
|
||||||
|
orientation: String? = nil,
|
||||||
|
paperSize: String? = nil,
|
||||||
|
mediaType: String? = nil,
|
||||||
|
ppdUncorrectedPassthrough: Bool? = nil,
|
||||||
|
cupsOptions: String? = nil
|
||||||
|
) {
|
||||||
|
self.paperSource = paperSource
|
||||||
|
self.orientation = orientation
|
||||||
|
self.paperSize = paperSize
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||||
|
self.cupsOptions = cupsOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult).
|
||||||
|
/// `nil` from the service means the user cancelled — never an error.
|
||||||
|
public struct PrintPropertiesResult: Codable, Equatable, Sendable {
|
||||||
|
/// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil`
|
||||||
|
/// when the `NSPrinter` fallback ran.
|
||||||
|
public var selectedPrinter: String?
|
||||||
|
public var options: PrintOptions
|
||||||
|
|
||||||
|
public init(selectedPrinter: String?, options: PrintOptions) {
|
||||||
|
self.selectedPrinter = selectedPrinter
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,3 +34,18 @@ public enum ProcessError: Error, Equatable, Sendable {
|
|||||||
/// stdin write failed (pipe closed / process gone).
|
/// stdin write failed (pipe closed / process gone).
|
||||||
case stdinFailed(String)
|
case stdinFailed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProcessError: LocalizedError {
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .duplicateID(let id):
|
||||||
|
return "Process already running: \(id)"
|
||||||
|
case .unknownID(let id):
|
||||||
|
return "Unknown process: \(id)"
|
||||||
|
case .spawnFailed(let detail):
|
||||||
|
return "Could not launch \(detail)"
|
||||||
|
case .stdinFailed(let detail):
|
||||||
|
return "stdin failed: \(detail)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,4 +14,10 @@ public enum ProcessID {
|
|||||||
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
|
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
|
||||||
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
|
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
|
||||||
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
|
||||||
|
|
||||||
|
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
|
||||||
|
/// streaming Argyll children.
|
||||||
|
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
|
||||||
|
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
|
||||||
|
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ public struct ProcessLineDecoder: Sendable {
|
|||||||
return rest.isEmpty ? nil : Self.decode(rest)
|
return rest.isEmpty ? nil : Self.decode(rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail as a single line and clears it.
|
||||||
|
/// Used by `ProcessManager.flushPartialLine` for tools that emit
|
||||||
|
/// progress dots without newlines.
|
||||||
|
public mutating func flushPartial() -> String? {
|
||||||
|
guard !pending.isEmpty else { return nil }
|
||||||
|
var rest = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||||
|
let text = Self.decode(rest)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
|
||||||
private static func decode(_ bytes: Data.SubSequence) -> String {
|
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||||
String(decoding: bytes, as: UTF8.self)
|
String(decoding: bytes, as: UTF8.self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Captured output from `runCaptured` (used by printcal/applycal —
|
/// Captured output from `runCaptured` — one-shot tools whose results
|
||||||
/// the only tools whose results arrive as one-shot output).
|
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
|
||||||
public struct CapturedResult: Sendable, Equatable {
|
public struct CapturedResult: Sendable, Equatable {
|
||||||
public let stdout: String
|
public let stdout: String
|
||||||
public let stderr: String
|
public let stderr: String
|
||||||
@@ -20,8 +20,11 @@ public struct CapturedResult: Sendable, Equatable {
|
|||||||
/// with the prefix stripped; all other stdout is `stdout` events.
|
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||||
/// - `exit` is emitted exactly once per child, and only after both
|
/// - `exit` is emitted exactly once per child, and only after both
|
||||||
/// output pipes reach EOF — so no buffered output is lost on fast
|
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||||
/// exits or kills.
|
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
|
||||||
/// - `kill` drops the stdin handle so writers fail fast.
|
/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
|
||||||
|
/// terminating. Hooks are removed once the child finalizes.
|
||||||
|
/// - `killAll` on `NSApplication.willTerminate` and last-window close
|
||||||
|
/// runs all hooks and terminates every child (#147, #149).
|
||||||
public actor ProcessManager {
|
public actor ProcessManager {
|
||||||
|
|
||||||
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||||
@@ -30,37 +33,54 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
// MARK: - Event bus (multicast)
|
// MARK: - Event bus (multicast)
|
||||||
|
|
||||||
private var subscribers: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
|
/// Lock-protected subscriber table. Registration is *synchronous*
|
||||||
|
/// inside `events()` so a caller can subscribe, then spawn, without
|
||||||
|
/// racing the child's first output or exit event.
|
||||||
|
private final class SubscriberBox: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var map: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
|
||||||
|
|
||||||
/// Subscribe to the event bus. Each call returns an independent
|
func add(_ continuation: AsyncStream<ProcessEvent>.Continuation, token: UUID) {
|
||||||
/// stream; every event is delivered to every live subscriber.
|
lock.lock()
|
||||||
public nonisolated func events() -> AsyncStream<ProcessEvent> {
|
map[token] = continuation
|
||||||
AsyncStream { continuation in
|
lock.unlock()
|
||||||
let token = UUID()
|
}
|
||||||
Task { await self.addSubscriber(continuation, token: token) }
|
|
||||||
continuation.onTermination = { _ in
|
func remove(_ token: UUID) {
|
||||||
Task { await self.removeSubscriber(token) }
|
lock.lock()
|
||||||
|
map.removeValue(forKey: token)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func yield(_ event: ProcessEvent) {
|
||||||
|
lock.lock()
|
||||||
|
let continuations = Array(map.values)
|
||||||
|
lock.unlock()
|
||||||
|
for continuation in continuations {
|
||||||
|
continuation.yield(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addSubscriber(
|
private nonisolated let subscriberBox = SubscriberBox()
|
||||||
_ continuation: AsyncStream<ProcessEvent>.Continuation,
|
|
||||||
token: UUID
|
|
||||||
) {
|
|
||||||
subscribers[token] = continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeSubscriber(_ token: UUID) {
|
/// Subscribe to the event bus. Each call returns an independent
|
||||||
subscribers.removeValue(forKey: token)
|
/// stream; every event is delivered to every live subscriber.
|
||||||
}
|
/// The subscriber is registered before `events()` returns — callers
|
||||||
|
/// may spawn immediately after subscribing without losing events.
|
||||||
private func emit(_ event: ProcessEvent) {
|
public nonisolated func events() -> AsyncStream<ProcessEvent> {
|
||||||
for continuation in subscribers.values {
|
let box = subscriberBox
|
||||||
continuation.yield(event)
|
let token = UUID()
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
box.add(continuation, token: token)
|
||||||
|
continuation.onTermination = { _ in box.remove(token) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated func emit(_ event: ProcessEvent) {
|
||||||
|
subscriberBox.yield(event)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Child registry
|
// MARK: - Child registry
|
||||||
|
|
||||||
private struct RunningChild {
|
private struct RunningChild {
|
||||||
@@ -75,12 +95,18 @@ public actor ProcessManager {
|
|||||||
/// pipes have also reached EOF.
|
/// pipes have also reached EOF.
|
||||||
var pendingExitCode: Int32?
|
var pendingExitCode: Int32?
|
||||||
var finalized = false
|
var finalized = false
|
||||||
|
/// Watchdog that forces finalization if EOFs never arrive.
|
||||||
|
var finalizeTask: Task<Void, Never>?
|
||||||
}
|
}
|
||||||
|
|
||||||
private var children: [String: RunningChild] = [:]
|
private var children: [String: RunningChild] = [:]
|
||||||
/// Processes owned by `runCaptured` (dup detection + kill support).
|
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||||
private var captured: [String: Process] = [:]
|
private var captured: [String: Process] = [:]
|
||||||
|
|
||||||
|
/// Hooks run by `kill` before terminating the child.
|
||||||
|
/// Used by `chartread` to park an XY head with `q\n`.
|
||||||
|
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
|
||||||
|
|
||||||
/// Ids of currently-running children.
|
/// Ids of currently-running children.
|
||||||
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||||
|
|
||||||
@@ -88,6 +114,14 @@ public actor ProcessManager {
|
|||||||
children[id] != nil || captured[id] != nil
|
children[id] != nil || captured[id] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Pre-kill hooks
|
||||||
|
|
||||||
|
/// Register a hook to run before `kill(id:)` terminates the child.
|
||||||
|
/// The hook is removed once the child finalizes.
|
||||||
|
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
|
||||||
|
preKillHooks[id] = hook
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Spawn (streaming)
|
// MARK: - Spawn (streaming)
|
||||||
|
|
||||||
/// Spawns a streaming child. Returns after spawn; callers wait for
|
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||||
@@ -125,14 +159,6 @@ public actor ProcessManager {
|
|||||||
stderrDecoder: ProcessLineDecoder()
|
stderrDecoder: ProcessLineDecoder()
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
} catch {
|
|
||||||
children.removeValue(forKey: id)
|
|
||||||
emit(.error(id: id, message: error.localizedDescription))
|
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||||
let stderrHandle = stderrPipe.fileHandleForReading
|
let stderrHandle = stderrPipe.fileHandleForReading
|
||||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||||
@@ -150,13 +176,23 @@ public actor ProcessManager {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn (captured)
|
// MARK: - Spawn (captured)
|
||||||
|
|
||||||
/// Runs a child to completion and returns all output. Reads stdout
|
/// Runs a child to completion and returns all output. Reads stdout
|
||||||
/// and stderr concurrently so a full pipe buffer can never deadlock
|
/// and stderr concurrently so a full pipe buffer can never deadlock
|
||||||
/// the child. Used by `printcal` / `applycal` (docs/03).
|
/// the child. Used by `printcal` / `applycal` (docs/03) and by
|
||||||
|
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
|
||||||
public func runCaptured(
|
public func runCaptured(
|
||||||
id: String,
|
id: String,
|
||||||
binary: URL,
|
binary: URL,
|
||||||
@@ -180,41 +216,114 @@ public actor ProcessManager {
|
|||||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register before run() so a concurrent duplicate spawn fails.
|
// Register and set up the termination hand-off before run() so
|
||||||
|
// a very fast exit is never missed (#50, #52).
|
||||||
captured[id] = process
|
captured[id] = process
|
||||||
|
|
||||||
|
let capturedProcess = process
|
||||||
|
|
||||||
|
// Box is local and synchronised with an NSLock; the @unchecked
|
||||||
|
// Sendable annotation is safe because all access is under the lock.
|
||||||
|
final class Box: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var status: Int32?
|
||||||
|
private var continuation: CheckedContinuation<Int32, Never>?
|
||||||
|
|
||||||
|
/// Try to resume an already-stored continuation with the exit
|
||||||
|
/// status. Returns true if a continuation was resumed.
|
||||||
|
func resume(with status: Int32) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
if let cont = continuation {
|
||||||
|
continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
cont.resume(returning: status)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
self.status = status
|
||||||
|
lock.unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a continuation, returning any status that arrived
|
||||||
|
/// before it. The caller must resume with the returned status.
|
||||||
|
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
|
||||||
|
lock.lock()
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
self.continuation = continuation
|
||||||
|
// A fast exit may have raced past the first nil-check.
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let box = Box()
|
||||||
|
capturedProcess.terminationHandler = { proc in
|
||||||
|
_ = box.resume(with: proc.terminationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
} catch {
|
} catch {
|
||||||
|
_ = box.resume(with: -1)
|
||||||
captured.removeValue(forKey: id)
|
captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
emit(.error(id: id, message: error.localizedDescription))
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
async let outData = Task.detached {
|
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||||
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
// as soon as the child exits; the child still has its own copies.
|
||||||
}.value
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
async let errData = Task.detached {
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
}.value
|
|
||||||
|
|
||||||
let code = await withCheckedContinuation { continuation in
|
return await withTaskCancellationHandler {
|
||||||
process.terminationHandler = { proc in
|
async let outData = Task.detached {
|
||||||
continuation.resume(returning: proc.terminationStatus)
|
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
async let errData = Task.detached {
|
||||||
|
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
|
||||||
|
let code = await withCheckedContinuation { continuation in
|
||||||
|
if let status = box.store(continuation) {
|
||||||
|
continuation.resume(returning: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (out, err) = await (outData, errData)
|
||||||
|
|
||||||
|
// Emit the real exit code once, regardless of whether kill()
|
||||||
|
// already removed the id from `captured`.
|
||||||
|
_ = captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
|
||||||
|
return CapturedResult(
|
||||||
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
|
exitCode: code
|
||||||
|
)
|
||||||
|
} onCancel: { [weak self] in
|
||||||
|
// If the awaiting Task is cancelled, terminate the child so
|
||||||
|
// callers like runApplycal never replace a good profile with
|
||||||
|
// a truncated tmp.
|
||||||
|
if capturedProcess.isRunning {
|
||||||
|
capturedProcess.terminate()
|
||||||
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.kill(id: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (out, err) = await (outData, errData)
|
|
||||||
// If kill() already reaped this child, its exit event went out.
|
|
||||||
if captured.removeValue(forKey: id) != nil {
|
|
||||||
emit(.exit(id: id, code: code))
|
|
||||||
}
|
|
||||||
|
|
||||||
return CapturedResult(
|
|
||||||
stdout: String(decoding: out, as: UTF8.self),
|
|
||||||
stderr: String(decoding: err, as: UTF8.self),
|
|
||||||
exitCode: code
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - stdin
|
// MARK: - stdin
|
||||||
@@ -237,39 +346,89 @@ public actor ProcessManager {
|
|||||||
try sendStdin(id: id, bytes: Data(text.utf8))
|
try sendStdin(id: id, bytes: Data(text.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Partial-line flush
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail of a streaming child's stdout
|
||||||
|
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
|
||||||
|
/// to flush progress dots without waiting for a newline.
|
||||||
|
public func flushPartialLine(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if let tail = child.stdoutDecoder.flushPartial() {
|
||||||
|
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let tail = child.stderrDecoder.flushPartial() {
|
||||||
|
emit(.stderr(id: id, line: tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
children[id] = child
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Kill
|
// MARK: - Kill
|
||||||
|
|
||||||
/// Terminates a child. The `exit` event still fires exactly once.
|
/// Terminates a child. First runs any registered pre-kill hook, then
|
||||||
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
/// drops stdin and signals the process. For streaming children the
|
||||||
public func kill(id: String) {
|
/// `exit` event is emitted once both stdout and stderr EOFs have been
|
||||||
|
/// seen (or the watchdog finalizes). For captured children the real
|
||||||
|
/// exit code is emitted by `runCaptured` itself.
|
||||||
|
public func kill(id: String) async {
|
||||||
|
if let hook = preKillHooks.removeValue(forKey: id) {
|
||||||
|
await hook()
|
||||||
|
}
|
||||||
|
|
||||||
if var child = children[id] {
|
if var child = children[id] {
|
||||||
try? child.stdin?.close()
|
try? child.stdin?.close()
|
||||||
child.stdin = nil
|
child.stdin = nil
|
||||||
children[id] = child
|
children[id] = child
|
||||||
|
|
||||||
if child.process.isRunning {
|
if child.process.isRunning {
|
||||||
child.process.terminate()
|
child.process.terminate()
|
||||||
} else {
|
} else if child.pendingExitCode == nil {
|
||||||
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
// The process already exited but `didTerminate` has not
|
||||||
|
// run; synthesize it so `maybeFinalize` can fire.
|
||||||
|
didTerminate(id: id, code: child.process.terminationStatus)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let process = captured[id] {
|
if let process = captured[id] {
|
||||||
if process.isRunning { process.terminate() }
|
if process.isRunning { process.terminate() }
|
||||||
if captured.removeValue(forKey: id) != nil {
|
// Do not emit `.exit` here; `runCaptured` emits the real code
|
||||||
emit(.exit(id: id, code: process.terminationStatus))
|
// after the process reaps.
|
||||||
}
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminates every running child; returns how many were signaled
|
/// Terminates every running child; returns how many were signaled
|
||||||
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func killAll() -> Int {
|
public func killAll() async -> Int {
|
||||||
let ids = Array(children.keys) + Array(captured.keys)
|
let ids = runningIDs
|
||||||
for id in ids { kill(id: id) }
|
for id in ids { await kill(id: id) }
|
||||||
return ids.count
|
return ids.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Force kill (SIGKILL fallback)
|
||||||
|
|
||||||
|
/// Sends `SIGKILL` to a streaming child if it is still running.
|
||||||
|
/// Used by the finalization watchdog when a graceful `terminate()`
|
||||||
|
/// does not cause the process to exit.
|
||||||
|
public func forceKill(id: String) {
|
||||||
|
guard let child = children[id],
|
||||||
|
!child.finalized,
|
||||||
|
child.process.isRunning
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let pid = child.process.processIdentifier
|
||||||
|
guard pid > 0 else { return }
|
||||||
|
_ = Darwin.kill(pid, SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||||
@@ -321,6 +480,16 @@ public actor ProcessManager {
|
|||||||
child.pendingExitCode = code
|
child.pendingExitCode = code
|
||||||
try? child.stdin?.close()
|
try? child.stdin?.close()
|
||||||
child.stdin = nil
|
child.stdin = nil
|
||||||
|
|
||||||
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
|
child.finalizeTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
guard let self else { return }
|
||||||
|
await self.forceKill(id: id)
|
||||||
|
await self.forceFinalize(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
children[id] = child
|
children[id] = child
|
||||||
maybeFinalize(id: id)
|
maybeFinalize(id: id)
|
||||||
}
|
}
|
||||||
@@ -333,8 +502,12 @@ public actor ProcessManager {
|
|||||||
child.stdoutEOF, child.stderrEOF,
|
child.stdoutEOF, child.stderrEOF,
|
||||||
!child.finalized
|
!child.finalized
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
child.finalized = true
|
child.finalized = true
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
children.removeValue(forKey: id)
|
children.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
|
||||||
// Flush unterminated tail lines.
|
// Flush unterminated tail lines.
|
||||||
if var decoder = Optional(child.stdoutDecoder),
|
if var decoder = Optional(child.stdoutDecoder),
|
||||||
@@ -351,4 +524,20 @@ public actor ProcessManager {
|
|||||||
}
|
}
|
||||||
emit(.exit(id: id, code: code))
|
emit(.exit(id: id, code: code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forces finalization even when one or both EOFs are missing.
|
||||||
|
/// Used by the `didTerminate` watchdog.
|
||||||
|
private func forceFinalize(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if child.pendingExitCode == nil {
|
||||||
|
child.pendingExitCode = -9
|
||||||
|
}
|
||||||
|
child.stdoutEOF = true
|
||||||
|
child.stderrEOF = true
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `applycal` argv construction.
|
||||||
|
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidCalibrationPath
|
||||||
|
case invalidInputProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCalibrationPath:
|
||||||
|
return "Calibration path is invalid or empty"
|
||||||
|
case .invalidInputProfileURL:
|
||||||
|
return "Input profile path is invalid or empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `applycal` tool.
|
||||||
|
///
|
||||||
|
/// `applycal` is always run captured, never streamed.
|
||||||
|
public enum ApplycalArgs {
|
||||||
|
|
||||||
|
/// Builds `applycal -v -a {cal} {input} [{output}]`.
|
||||||
|
///
|
||||||
|
/// `-u` (unapply) is rejected at the builder level — the UI never
|
||||||
|
/// sends it (docs/04 §7.2).
|
||||||
|
public static func build(config: ApplycalConfig) throws -> [String] {
|
||||||
|
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
|
||||||
|
|
||||||
|
let input = config.inputProfileURL.path
|
||||||
|
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
if config.unapply {
|
||||||
|
// Defensive: should never be called from the UI.
|
||||||
|
args.append("-u")
|
||||||
|
} else {
|
||||||
|
args.append("-a")
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(contentsOf: [cal, input])
|
||||||
|
|
||||||
|
if let output = config.outputProfileURL?.path, !output.isEmpty {
|
||||||
|
args.append(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `applycal` run.
|
||||||
|
public struct ApplycalConfig: Sendable, Equatable {
|
||||||
|
public var calibrationPath: String
|
||||||
|
public var inputProfileURL: URL
|
||||||
|
public var outputProfileURL: URL?
|
||||||
|
public var unapply: Bool
|
||||||
|
|
||||||
|
/// In-place when `outputProfileURL` is `nil`.
|
||||||
|
public init(
|
||||||
|
calibrationPath: String,
|
||||||
|
inputProfileURL: URL,
|
||||||
|
outputProfileURL: URL? = nil,
|
||||||
|
unapply: Bool = false
|
||||||
|
) {
|
||||||
|
self.calibrationPath = calibrationPath
|
||||||
|
self.inputProfileURL = inputProfileURL
|
||||||
|
self.outputProfileURL = outputProfileURL
|
||||||
|
self.unapply = unapply
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `colprof` argv construction.
|
||||||
|
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid colprof basename: \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `colprof` tool.
|
||||||
|
public enum ColprofArgs {
|
||||||
|
|
||||||
|
/// Builds `colprof` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
|
||||||
|
/// only when their fields are non-empty and meaningful. `-f` has
|
||||||
|
/// special handling for "none" (omit), "" (bare flag), and a custom
|
||||||
|
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
|
||||||
|
/// skipped when set to "none".
|
||||||
|
public static func build(config: ColprofConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
|
||||||
|
args.append(contentsOf: ["-a", config.algorithm])
|
||||||
|
args.append(contentsOf: ["-q", config.quality])
|
||||||
|
|
||||||
|
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
|
||||||
|
args.append(contentsOf: ["-t", intent])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||||
|
switch fwa.lowercased() {
|
||||||
|
case "none", "":
|
||||||
|
// "none" omits the flag; an explicit empty string means bare -f.
|
||||||
|
if fwa.isEmpty {
|
||||||
|
args.append("-f")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
args.append(contentsOf: ["-f", fwa])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
|
||||||
|
args.append(contentsOf: ["-i", illuminant])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
|
||||||
|
args.append(contentsOf: ["-o", observer])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-c", inputCond])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-d", outputCond])
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let description = profileDescription, !description.isEmpty {
|
||||||
|
args.append(contentsOf: ["-D", description])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
|
||||||
|
args.append(contentsOf: ["-C", copyright])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
|
||||||
|
public struct ColprofConfig: Sendable, Equatable {
|
||||||
|
public var algorithm: String
|
||||||
|
public var quality: String
|
||||||
|
public var intent: String?
|
||||||
|
public var fwa: String?
|
||||||
|
public var illuminant: String?
|
||||||
|
public var observer: String?
|
||||||
|
public var inputViewingCond: String?
|
||||||
|
public var outputViewingCond: String?
|
||||||
|
public var description: String?
|
||||||
|
public var copyright: String?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
algorithm: String = "l",
|
||||||
|
quality: String = "m",
|
||||||
|
intent: String? = nil,
|
||||||
|
fwa: String? = nil,
|
||||||
|
illuminant: String? = nil,
|
||||||
|
observer: String? = nil,
|
||||||
|
inputViewingCond: String? = nil,
|
||||||
|
outputViewingCond: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
copyright: String? = nil,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.quality = quality
|
||||||
|
self.intent = intent
|
||||||
|
self.fwa = fwa
|
||||||
|
self.illuminant = illuminant
|
||||||
|
self.observer = observer
|
||||||
|
self.inputViewingCond = inputViewingCond
|
||||||
|
self.outputViewingCond = outputViewingCond
|
||||||
|
self.description = description
|
||||||
|
self.copyright = copyright
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Classified `colprof` stdout progress milestone.
|
||||||
|
public enum ColprofProgress: Sendable, Equatable {
|
||||||
|
case gamutMapping
|
||||||
|
case fittingClut
|
||||||
|
case writingIcc
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `colprof` plaintext progress (docs/16 §6.3).
|
||||||
|
///
|
||||||
|
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
|
||||||
|
/// Progress is therefore inferred from case-insensitive substring matches.
|
||||||
|
public enum ColprofProgressClassifier {
|
||||||
|
|
||||||
|
public static func classify(line: String) -> ColprofProgress {
|
||||||
|
let lower = line.lowercased()
|
||||||
|
if lower.contains("gamut mapping") {
|
||||||
|
return .gamutMapping
|
||||||
|
}
|
||||||
|
if lower.contains("fitting") || lower.contains("clut") {
|
||||||
|
return .fittingClut
|
||||||
|
}
|
||||||
|
if lower.contains("writing") || lower.contains("icc profile") {
|
||||||
|
return .writingIcc
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Computes a consecutive-breach warning from verification history.
|
||||||
|
///
|
||||||
|
/// A drift alert triggers when the most recent chronologically consecutive
|
||||||
|
/// poor records form a run of at least two, and the first and last of that
|
||||||
|
/// run are on distinct UTC days or at least one hour apart.
|
||||||
|
public enum DriftAlert {
|
||||||
|
|
||||||
|
/// Returns an alert message, or `nil` when no consecutive breach exists.
|
||||||
|
public static func compute(from records: [VerificationRecord]) -> String? {
|
||||||
|
// Work in chronological order.
|
||||||
|
let chronological = records.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
|
||||||
|
// Build the longest suffix of consecutive `.poor` records.
|
||||||
|
// Non-poor records break the run, so we stop at the first non-poor
|
||||||
|
// encountered from the end.
|
||||||
|
var run: [VerificationRecord] = []
|
||||||
|
for record in chronological.reversed() {
|
||||||
|
if record.status == .poor {
|
||||||
|
run.insert(record, at: 0)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard run.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let first = run.first!
|
||||||
|
let last = run.last!
|
||||||
|
|
||||||
|
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
|
||||||
|
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
|
||||||
|
|
||||||
|
if !sameDay || oneHour {
|
||||||
|
return "Drift alert: poor results between \(first.id) and \(last.id)."
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Calendar {
|
||||||
|
static let utc: Calendar = {
|
||||||
|
var c = Calendar(identifier: .iso8601)
|
||||||
|
c.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `iccgamut` argv construction.
|
||||||
|
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidProfileURL:
|
||||||
|
return "iccgamut requires a valid profile path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `iccgamut` tool.
|
||||||
|
public enum IccgamutArgs {
|
||||||
|
|
||||||
|
/// Builds `iccgamut -v -d {density} {profilePath}`.
|
||||||
|
///
|
||||||
|
/// The caller is responsible for ensuring `density` is a positive
|
||||||
|
/// integer. `-d` here is surface **density**, not a directory.
|
||||||
|
public static func build(config: IccgamutConfig) throws -> [String] {
|
||||||
|
let path = config.profileURL.path
|
||||||
|
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
|
||||||
|
|
||||||
|
let density = max(1, config.density)
|
||||||
|
return ["-v", "-d", "\(density)", path]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `iccgamut` run.
|
||||||
|
public struct IccgamutConfig: Sendable, Equatable {
|
||||||
|
public var profileURL: URL
|
||||||
|
public var density: Int
|
||||||
|
|
||||||
|
public init(profileURL: URL, density: Int = 10) {
|
||||||
|
self.profileURL = profileURL
|
||||||
|
self.density = density
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a profile installation.
|
||||||
|
public struct InstallProfileConfig: Sendable, Equatable {
|
||||||
|
public var sourceURL: URL
|
||||||
|
public var options: InstallProfileOptions
|
||||||
|
|
||||||
|
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Collision policy for profile installation.
|
||||||
|
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case overwrite
|
||||||
|
case rename
|
||||||
|
case cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for installing a finished profile into the OS colour store.
|
||||||
|
public struct InstallProfileOptions: Sendable, Equatable, Codable {
|
||||||
|
public var forceOverwrite: Bool
|
||||||
|
public var preferSystem: Bool
|
||||||
|
public var collisionPolicy: ProfileCollisionPolicy
|
||||||
|
public var openColorPanel: Bool
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
forceOverwrite: Bool = false,
|
||||||
|
preferSystem: Bool = false,
|
||||||
|
collisionPolicy: ProfileCollisionPolicy = .cancel,
|
||||||
|
openColorPanel: Bool = false,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.forceOverwrite = forceOverwrite
|
||||||
|
self.preferSystem = preferSystem
|
||||||
|
self.collisionPolicy = collisionPolicy
|
||||||
|
self.openColorPanel = openColorPanel
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of installing a profile into the OS colour store.
|
||||||
|
public struct InstallProfileResult: Sendable, Equatable, Codable {
|
||||||
|
public var destPath: String
|
||||||
|
public var registered: Bool
|
||||||
|
public var overwritten: Bool
|
||||||
|
public var renamed: Bool
|
||||||
|
public var openedPanel: Bool
|
||||||
|
public var message: String
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
destPath: String,
|
||||||
|
registered: Bool,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool,
|
||||||
|
openedPanel: Bool,
|
||||||
|
message: String,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.destPath = destPath
|
||||||
|
self.registered = registered
|
||||||
|
self.overwritten = overwritten
|
||||||
|
self.renamed = renamed
|
||||||
|
self.openedPanel = openedPanel
|
||||||
|
self.message = message
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `profcheck` argv construction.
|
||||||
|
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case missingTi3
|
||||||
|
case missingIcc
|
||||||
|
case invalidTi3Path
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingTi3:
|
||||||
|
return "profcheck requires a .ti3 file"
|
||||||
|
case .missingIcc:
|
||||||
|
return "profcheck requires a profile (.icc/.icm)"
|
||||||
|
case .invalidTi3Path:
|
||||||
|
return "profcheck .ti3 path is invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `profcheck` tool.
|
||||||
|
public enum ProfcheckArgs {
|
||||||
|
|
||||||
|
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
|
||||||
|
///
|
||||||
|
/// The `-u` here is the Gronod fork JSON report flag, not the
|
||||||
|
/// generic `-u` auto-fix that some Argyll builds use.
|
||||||
|
public static func build(config: ProfcheckConfig) throws -> [String] {
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
let iccPath = config.iccURL.path
|
||||||
|
|
||||||
|
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
|
||||||
|
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
|
||||||
|
|
||||||
|
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `profcheck` run.
|
||||||
|
public struct ProfcheckConfig: Sendable, Equatable {
|
||||||
|
public var ti3URL: URL
|
||||||
|
public var iccURL: URL
|
||||||
|
|
||||||
|
public init(ti3URL: URL, iccURL: URL) {
|
||||||
|
self.ti3URL = ti3URL
|
||||||
|
self.iccURL = iccURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors returned when `profcheck` output cannot be parsed.
|
||||||
|
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unparseable
|
||||||
|
case jsonDecodingFailed
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unparseable:
|
||||||
|
return "Could not parse profcheck report"
|
||||||
|
case .jsonDecodingFailed:
|
||||||
|
return "profcheck JSON report could not be decoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
|
||||||
|
public enum ProfcheckParser {
|
||||||
|
|
||||||
|
/// Parsing order (issue #25):
|
||||||
|
/// 1. Patch count from `No of test patches = N`.
|
||||||
|
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
|
||||||
|
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
|
||||||
|
/// 4. Broad regex fallback.
|
||||||
|
/// 5. If no metrics found, return a report whose `warning` is set.
|
||||||
|
public static func parse(_ output: String) -> ProfcheckReport {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
// 1. Patch count.
|
||||||
|
let patchRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"No of test patches\s*=\s*(\d+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = patchRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
), let range = Range(match.range(at: 1), in: output) {
|
||||||
|
let count = Int(output[range])
|
||||||
|
report.patchCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JSON objects.
|
||||||
|
let jsonObjects = extractJSONObjects(from: output)
|
||||||
|
for object in jsonObjects {
|
||||||
|
if let event = object["event"] as? String, event == "report" {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasMetricKeys(object) {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Legacy text.
|
||||||
|
let textRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = textRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
) {
|
||||||
|
let numbers = (1...3).compactMap { i -> Double? in
|
||||||
|
guard let range = Range(match.range(at: i), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
if numbers.count == 3 {
|
||||||
|
report.maxDE = numbers[0]
|
||||||
|
report.avgDE = numbers[1]
|
||||||
|
report.rmsDE = numbers[2]
|
||||||
|
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Broad regex fallback.
|
||||||
|
if let fallback = parseRegexFallback(output) {
|
||||||
|
var merged = fallback
|
||||||
|
merged.patchCount = report.patchCount
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unparseable.
|
||||||
|
report.warning = "profcheck output did not contain a recognisable report."
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON extraction
|
||||||
|
|
||||||
|
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
|
||||||
|
var objects: [[String: Any]] = []
|
||||||
|
var start: String.Index?
|
||||||
|
var depth = 0
|
||||||
|
|
||||||
|
for index in output.indices {
|
||||||
|
let char = output[index]
|
||||||
|
if char == "{" {
|
||||||
|
if depth == 0 {
|
||||||
|
start = index
|
||||||
|
}
|
||||||
|
depth += 1
|
||||||
|
} else if char == "}" {
|
||||||
|
if depth > 0 {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0, let start = start {
|
||||||
|
let jsonString = String(output[start...index])
|
||||||
|
if let data = jsonString.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
objects.append(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
|
||||||
|
let keys = [
|
||||||
|
"avg_de", "avg_de2000",
|
||||||
|
"peak_de", "peak_de2000", "max_de",
|
||||||
|
"rms", "rms_de"
|
||||||
|
]
|
||||||
|
return keys.contains { object[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metrics {
|
||||||
|
var avg: Double?
|
||||||
|
var max: Double?
|
||||||
|
var rms: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func metrics(from object: [String: Any]) -> Metrics? {
|
||||||
|
var m = Metrics()
|
||||||
|
m.avg = doubleValue(for: "avg_de2000", in: object)
|
||||||
|
?? doubleValue(for: "avg_de", in: object)
|
||||||
|
m.max = doubleValue(for: "peak_de2000", in: object)
|
||||||
|
?? doubleValue(for: "peak_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de2000", in: object)
|
||||||
|
m.rms = doubleValue(for: "rms", in: object)
|
||||||
|
?? doubleValue(for: "rms_de", in: object)
|
||||||
|
?? doubleValue(for: "rms_de2000", in: object)
|
||||||
|
|
||||||
|
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
|
||||||
|
if let number = object[key] as? Double { return number }
|
||||||
|
if let number = object[key] as? NSNumber { return number.doubleValue }
|
||||||
|
if let string = object[key] as? String { return Double(string) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
|
||||||
|
report.avgDE = metrics.avg
|
||||||
|
report.maxDE = metrics.max
|
||||||
|
report.rmsDE = metrics.rms
|
||||||
|
if let avg = metrics.avg {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Regex fallback
|
||||||
|
|
||||||
|
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
let avgRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let maxRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let rmsRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
|
||||||
|
report.avgDE = firstDouble(from: output, regex: avgRegex)
|
||||||
|
report.maxDE = firstDouble(from: output, regex: maxRegex)
|
||||||
|
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
|
||||||
|
|
||||||
|
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let avg = report.avgDE {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
|
||||||
|
guard let regex = regex,
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
),
|
||||||
|
let range = Range(match.range(at: 1), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed result from a `profcheck -u` run.
|
||||||
|
public struct ProfcheckReport: Sendable, Equatable, Codable {
|
||||||
|
public var patchCount: Int?
|
||||||
|
public var avgDE: Double?
|
||||||
|
public var maxDE: Double?
|
||||||
|
public var rmsDE: Double?
|
||||||
|
public var status: VerificationStatus?
|
||||||
|
public var warning: String?
|
||||||
|
|
||||||
|
public var isValid: Bool {
|
||||||
|
avgDE != nil && maxDE != nil && rmsDE != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
patchCount: Int? = nil,
|
||||||
|
avgDE: Double? = nil,
|
||||||
|
maxDE: Double? = nil,
|
||||||
|
rmsDE: Double? = nil,
|
||||||
|
status: VerificationStatus? = nil,
|
||||||
|
warning: String? = nil
|
||||||
|
) {
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.status = status
|
||||||
|
self.warning = warning
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown by `ProfileInstaller`.
|
||||||
|
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unsafeStem(String)
|
||||||
|
case sourceMissing
|
||||||
|
case sourceNotProfile
|
||||||
|
case sourceTooSmall
|
||||||
|
case systemRequiresAdminRights
|
||||||
|
case copyFailed(String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsafeStem(let stem):
|
||||||
|
return "Profile name contains unsafe characters: \(stem)"
|
||||||
|
case .sourceMissing:
|
||||||
|
return "Source profile does not exist"
|
||||||
|
case .sourceNotProfile:
|
||||||
|
return "Source must be a .icc or .icm file"
|
||||||
|
case .sourceTooSmall:
|
||||||
|
return "Source file is too small to be a valid profile"
|
||||||
|
case .systemRequiresAdminRights:
|
||||||
|
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
|
||||||
|
case .copyFailed(let reason):
|
||||||
|
return "Could not install profile: \(reason)"
|
||||||
|
case .cancelled:
|
||||||
|
return "Install cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs an ICC/ICM profile into the OS colour store.
|
||||||
|
public enum ProfileInstaller {
|
||||||
|
|
||||||
|
/// Resolves the destination URL that `install` would write to for the
|
||||||
|
/// given source and options, without copying anything. Useful for
|
||||||
|
/// collision previews in the UI.
|
||||||
|
public static func resolveDestinationURL(
|
||||||
|
for config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
|
||||||
|
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
|
||||||
|
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
|
||||||
|
public static func install(
|
||||||
|
config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
|
||||||
|
// Source validation.
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
guard fm.fileExists(atPath: sourceURL.path) else {
|
||||||
|
throw ProfileInstallError.sourceMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
// Destination directory.
|
||||||
|
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
|
||||||
|
try? fm.createDirectory(
|
||||||
|
at: destURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collision resolution.
|
||||||
|
let destExists = fm.fileExists(atPath: destURL.path)
|
||||||
|
if destExists {
|
||||||
|
if config.options.forceOverwrite {
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: true,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .rename {
|
||||||
|
let epoch = Int(Date().timeIntervalSince1970)
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
let renamedURL = destURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: renamedURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: true
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .cancel {
|
||||||
|
throw ProfileInstallError.cancelled
|
||||||
|
} else {
|
||||||
|
// Default with askBeforeOverwrite — the app must decide.
|
||||||
|
throw ProfileInstallError.copyFailed("destination already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private static func validateSourceURL(_ sourceURL: URL) throws {
|
||||||
|
let path = sourceURL.path
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
|
||||||
|
// Reject backslashes anywhere in the path.
|
||||||
|
guard !path.contains("\\") else {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject any path component that is literally "." or "..".
|
||||||
|
// This allows names like "foo..bar" while blocking real traversal.
|
||||||
|
for component in sourceURL.pathComponents {
|
||||||
|
if component == "." || component == ".." {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func destinationDirectory(
|
||||||
|
for options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> URL {
|
||||||
|
if options.preferSystem {
|
||||||
|
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
|
||||||
|
} else {
|
||||||
|
return fileManager.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/ColorSync/Profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func performInstall(
|
||||||
|
from sourceURL: URL,
|
||||||
|
to destURL: URL,
|
||||||
|
options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
|
||||||
|
|
||||||
|
// Remove stale tmp.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fm.copyItem(at: sourceURL, to: tmpURL)
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let tmpSize = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard tmpSize >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: destURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: destURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
// Surface a clear admin-rights hint when writing to system.
|
||||||
|
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
|
||||||
|
throw ProfileInstallError.systemRequiresAdminRights
|
||||||
|
}
|
||||||
|
|
||||||
|
if let installError = error as? ProfileInstallError {
|
||||||
|
throw installError
|
||||||
|
}
|
||||||
|
throw ProfileInstallError.copyFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = fm.fileExists(atPath: destURL.path)
|
||||||
|
|
||||||
|
var openedPanel = false
|
||||||
|
if options.openColorPanel {
|
||||||
|
openedPanel = openColorSyncUtility()
|
||||||
|
}
|
||||||
|
|
||||||
|
return InstallProfileResult(
|
||||||
|
destPath: destURL.path,
|
||||||
|
registered: registered,
|
||||||
|
overwritten: overwritten,
|
||||||
|
renamed: renamed,
|
||||||
|
openedPanel: openedPanel,
|
||||||
|
message: "Profile installed to \(destURL.path)",
|
||||||
|
calibrationNote: options.calibrationNote
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func openColorSyncUtility() -> Bool {
|
||||||
|
let task = Process()
|
||||||
|
task.launchPath = "/usr/bin/open"
|
||||||
|
task.arguments = ["-a", "ColorSync Utility"]
|
||||||
|
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
|
||||||
|
do {
|
||||||
|
try task.run()
|
||||||
|
task.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `VerificationRecord` entries.
|
||||||
|
public actor VerificationHistoryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 1000
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var records: [VerificationRecord] = []
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
self.encoder.dateEncodingStrategy = .iso8601
|
||||||
|
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.decoder.dateDecodingStrategy = .iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads records from disk. Returns the existing cache if already loaded.
|
||||||
|
///
|
||||||
|
/// Throws when the file exists but cannot be parsed; the existing file
|
||||||
|
/// is never overwritten in that case.
|
||||||
|
public func load() throws -> [VerificationRecord] {
|
||||||
|
guard records.isEmpty else { return records }
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: url.path),
|
||||||
|
let data = try? Data(contentsOf: url) else { return [] }
|
||||||
|
records = try decoder.decode([VerificationRecord].self, from: data)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all records.
|
||||||
|
public func all() -> [VerificationRecord] {
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records matching the optional printer filter.
|
||||||
|
public func filtered(by printer: String?) -> [VerificationRecord] {
|
||||||
|
guard let printer = printer, !printer.isEmpty else { return records }
|
||||||
|
return records.filter { $0.printerName == printer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a record, trims to capacity, and writes atomically.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = records
|
||||||
|
updated.append(record)
|
||||||
|
if updated.count > capacity {
|
||||||
|
updated.sort { $0.timestamp < $1.timestamp }
|
||||||
|
updated = Array(updated.suffix(capacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
try write(updated)
|
||||||
|
records = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all history and updates disk.
|
||||||
|
public func clear() throws {
|
||||||
|
try write([])
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC-4180 CSV export.
|
||||||
|
public func exportCSV() -> String {
|
||||||
|
var lines: [String] = [
|
||||||
|
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
|
||||||
|
]
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
lines.append(csvRow([
|
||||||
|
record.id,
|
||||||
|
record.profileName,
|
||||||
|
record.printerName,
|
||||||
|
String(record.avgDE),
|
||||||
|
String(record.maxDE),
|
||||||
|
String(record.rmsDE),
|
||||||
|
String(record.patchCount),
|
||||||
|
record.status.rawValue,
|
||||||
|
ISO8601DateFormatter().string(from: record.timestamp)
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `records` through a temp file and rename.
|
||||||
|
private func write(_ records: [VerificationRecord]) throws {
|
||||||
|
let data = try encoder.encode(records)
|
||||||
|
try AtomicFileWriter.write(data, to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func csvRow(_ fields: [String]) -> String {
|
||||||
|
fields.map { field in
|
||||||
|
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
|
||||||
|
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
}.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single entry in the verification history store.
|
||||||
|
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
|
||||||
|
public var id: String
|
||||||
|
public var profileName: String
|
||||||
|
public var printerName: String
|
||||||
|
public var avgDE: Double
|
||||||
|
public var maxDE: Double
|
||||||
|
public var rmsDE: Double
|
||||||
|
public var patchCount: Int
|
||||||
|
public var status: VerificationStatus
|
||||||
|
public var timestamp: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
profileName: String,
|
||||||
|
printerName: String,
|
||||||
|
avgDE: Double,
|
||||||
|
maxDE: Double,
|
||||||
|
rmsDE: Double,
|
||||||
|
patchCount: Int,
|
||||||
|
status: VerificationStatus,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.profileName = profileName
|
||||||
|
self.printerName = printerName
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.status = status
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// ICCery quality band for a verification run.
|
||||||
|
///
|
||||||
|
/// Bands are on the **average** ΔE₀₀:
|
||||||
|
/// - < 1.0 → Excellent
|
||||||
|
/// - < 2.0 → Good
|
||||||
|
/// - < 3.5 → Acceptable
|
||||||
|
/// - ≥ 3.5 → Warning
|
||||||
|
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case excellent
|
||||||
|
case good
|
||||||
|
case acceptable
|
||||||
|
case poor
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .excellent: return "Excellent"
|
||||||
|
case .good: return "Good"
|
||||||
|
case .acceptable: return "Acceptable"
|
||||||
|
case .poor: return "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the quality band for the given average ΔE₀₀.
|
||||||
|
public static func from(avgDE: Double) -> VerificationStatus {
|
||||||
|
if avgDE < 1.0 { return .excellent }
|
||||||
|
if avgDE < 2.0 { return .good }
|
||||||
|
if avgDE < 3.5 { return .acceptable }
|
||||||
|
return .poor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained
|
||||||
|
/// solely to decode and migrate pre-M2 `settings.json`; new code uses
|
||||||
|
/// `ProfilingPreset` (docs/22 §ProfilingPreset).
|
||||||
|
public struct CustomPreset: Codable, Equatable, Sendable {
|
||||||
|
public var name: String
|
||||||
|
/// Opaque per-stage form values — keyed by field id.
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(name: String, values: [String: String] = [:]) {
|
||||||
|
self.name = name
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `install_profile` drops finished profiles (docs/22).
|
||||||
|
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
|
||||||
|
case user
|
||||||
|
case system
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `settings.json` model (docs/22). snake_case keys match the v1 file
|
||||||
|
/// so field names stay identical across rewrites.
|
||||||
|
///
|
||||||
|
/// Decoding is tolerant: missing keys take documented defaults and each
|
||||||
|
/// `custom_presets` element is tried as a typed `ProfilingPreset` first
|
||||||
|
/// and as a legacy M1 `CustomPreset` second — a malformed entry never
|
||||||
|
/// drops the rest of the array (preset migration, issue #11).
|
||||||
|
public struct AppSettings: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// User override for Argyll binaries; `nil` → bundled sidecars.
|
||||||
|
public var argyllBinaryDir: String?
|
||||||
|
|
||||||
|
/// Stored but **never applied to argv** — Stage 2's own instrument
|
||||||
|
/// select is the live source (docs/04 §0.1).
|
||||||
|
public var defaultInstrument: String?
|
||||||
|
|
||||||
|
/// `nil` → `.debug` in debug builds, `.info` in release (#158).
|
||||||
|
public var logLevel: LogLevel?
|
||||||
|
|
||||||
|
public var deltaEGoodMax: Double
|
||||||
|
public var deltaEWarningMax: Double
|
||||||
|
public var customPresets: [ProfilingPreset]
|
||||||
|
public var enableI1Pro2Leds: Bool
|
||||||
|
public var calibrationStaleDays: Int
|
||||||
|
public var defaultInstallLocation: InstallLocation
|
||||||
|
public var askBeforeOverwriteProfile: Bool
|
||||||
|
public var openColorPanelAfterInstall: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
argyllBinaryDir: String? = nil,
|
||||||
|
defaultInstrument: String? = nil,
|
||||||
|
logLevel: LogLevel? = nil,
|
||||||
|
deltaEGoodMax: Double = 2.0,
|
||||||
|
deltaEWarningMax: Double = 5.0,
|
||||||
|
customPresets: [ProfilingPreset] = [],
|
||||||
|
enableI1Pro2Leds: Bool = false,
|
||||||
|
calibrationStaleDays: Int = 30,
|
||||||
|
defaultInstallLocation: InstallLocation = .user,
|
||||||
|
askBeforeOverwriteProfile: Bool = true,
|
||||||
|
openColorPanelAfterInstall: Bool = false
|
||||||
|
) {
|
||||||
|
self.argyllBinaryDir = argyllBinaryDir
|
||||||
|
self.defaultInstrument = defaultInstrument
|
||||||
|
self.logLevel = logLevel
|
||||||
|
self.deltaEGoodMax = deltaEGoodMax
|
||||||
|
self.deltaEWarningMax = deltaEWarningMax
|
||||||
|
self.customPresets = customPresets
|
||||||
|
self.enableI1Pro2Leds = enableI1Pro2Leds
|
||||||
|
self.calibrationStaleDays = calibrationStaleDays
|
||||||
|
self.defaultInstallLocation = defaultInstallLocation
|
||||||
|
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
|
||||||
|
self.openColorPanelAfterInstall = openColorPanelAfterInstall
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = AppSettings()
|
||||||
|
|
||||||
|
/// Effective log level — runtime state, not just persistence (#158).
|
||||||
|
public var effectiveLogLevel: LogLevel {
|
||||||
|
if let logLevel { return logLevel }
|
||||||
|
#if DEBUG
|
||||||
|
return .debug
|
||||||
|
#else
|
||||||
|
return .info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case argyllBinaryDir = "argyll_binary_dir"
|
||||||
|
case defaultInstrument = "default_instrument"
|
||||||
|
case logLevel = "log_level"
|
||||||
|
case deltaEGoodMax = "delta_e_good_max"
|
||||||
|
case deltaEWarningMax = "delta_e_warning_max"
|
||||||
|
case customPresets = "custom_presets"
|
||||||
|
case enableI1Pro2Leds = "enable_i1pro2_leds"
|
||||||
|
case calibrationStaleDays = "calibration_stale_days"
|
||||||
|
case defaultInstallLocation = "default_install_location"
|
||||||
|
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
|
||||||
|
case openColorPanelAfterInstall = "open_color_panel_after_install"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One element of `custom_presets`: typed first, legacy M1 second.
|
||||||
|
private enum AnyPreset: Decodable {
|
||||||
|
case typed(ProfilingPreset)
|
||||||
|
case legacy(CustomPreset)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
if let p = try? ProfilingPreset(from: decoder) {
|
||||||
|
self = .typed(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self = .legacy(try CustomPreset(from: decoder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let d = AppSettings.default
|
||||||
|
argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir
|
||||||
|
defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument
|
||||||
|
logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel
|
||||||
|
deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax
|
||||||
|
deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax
|
||||||
|
enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds
|
||||||
|
calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays
|
||||||
|
defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation
|
||||||
|
askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile
|
||||||
|
openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall
|
||||||
|
|
||||||
|
// Per-element decode: typed presets win; a legacy M1 shape
|
||||||
|
// ({"name","values"}) migrates; unconvertible entries are
|
||||||
|
// skipped so one bad record never drops the array.
|
||||||
|
let elements = (try? c.decodeIfPresent(
|
||||||
|
[FailableDecodable<AnyPreset>].self, forKey: .customPresets
|
||||||
|
)) ?? nil
|
||||||
|
var migrated: [ProfilingPreset] = []
|
||||||
|
for (index, element) in (elements ?? []).enumerated() {
|
||||||
|
switch element.value {
|
||||||
|
case .typed(let preset):
|
||||||
|
migrated.append(preset)
|
||||||
|
case .legacy(let legacy):
|
||||||
|
if let converted = ProfilingPreset(migrating: legacy, index: index) {
|
||||||
|
migrated.append(converted)
|
||||||
|
} else {
|
||||||
|
AppLogger(category: "settings").warn(
|
||||||
|
"Skipped unmigratable legacy preset: \(legacy.name)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .none:
|
||||||
|
AppLogger(category: "settings").warn(
|
||||||
|
"Skipped malformed preset entry at index \(index)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customPresets = migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-facing validation. Strings are part of the contract (issue #5).
|
||||||
|
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
|
||||||
|
public static let errorThresholdOrder =
|
||||||
|
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||||
|
|
||||||
|
/// All validation errors, in declaration order. Empty = valid.
|
||||||
|
public func validate() -> [String] {
|
||||||
|
var errors: [String] = []
|
||||||
|
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
|
||||||
|
errors.append(Self.errorNegativeDeltaE)
|
||||||
|
}
|
||||||
|
if deltaEGoodMax >= deltaEWarningMax {
|
||||||
|
errors.append(Self.errorThresholdOrder)
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isValid: Bool { validate().isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfilingPreset {
|
||||||
|
|
||||||
|
/// Migrates a legacy M1 `CustomPreset` (`name` + string values) to
|
||||||
|
/// the typed schema. Known keys are coerced; anything else is
|
||||||
|
/// ignored. Returns `nil` only when the name is unusable — a
|
||||||
|
/// deterministic `custom-{index}-{slug}` id is always produced.
|
||||||
|
init?(migrating legacy: CustomPreset, index: Int) {
|
||||||
|
let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let v = legacy.values
|
||||||
|
func int(_ key: String) -> Int? {
|
||||||
|
v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
}
|
||||||
|
func double(_ key: String) -> Double? {
|
||||||
|
v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
}
|
||||||
|
func bool(_ key: String) -> Bool? {
|
||||||
|
v[key].flatMap { s in
|
||||||
|
switch s.trimmingCharacters(in: .whitespaces).lowercased() {
|
||||||
|
case "true", "1", "yes": return true
|
||||||
|
case "false", "0", "no": return false
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func string(_ key: String) -> String? {
|
||||||
|
v[key].map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = trimmedName.lowercased()
|
||||||
|
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
|
||||||
|
.reduce(into: "") { $0.append($1) }
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: "custom-\(index)-\(slug)",
|
||||||
|
name: trimmedName,
|
||||||
|
description: string("description") ?? "",
|
||||||
|
colourSpace: string("colour_space")?.lowercased() ?? "rgb",
|
||||||
|
patchCount: int("patch_count") ?? 800,
|
||||||
|
whitePatches: int("white_patches") ?? 4,
|
||||||
|
blackPatches: int("black_patches") ?? 4,
|
||||||
|
greySteps: int("grey_steps"),
|
||||||
|
singleChannelSteps: int("single_channel_steps"),
|
||||||
|
neutralSteps: int("neutral_steps"),
|
||||||
|
neutralConcentration: double("neutral_concentration"),
|
||||||
|
preconditioningProfile: string("preconditioning_profile"),
|
||||||
|
ofpsHighQuality: bool("ofps_high_quality"),
|
||||||
|
ofpsAdaptation: double("ofps_adaptation"),
|
||||||
|
fullSpreadAlgorithm: string("full_spread_algorithm"),
|
||||||
|
totalInkLimit: int("total_ink_limit"),
|
||||||
|
darkEmphasis: double("dark_emphasis"),
|
||||||
|
devicePower: double("device_power"),
|
||||||
|
instrument: string("instrument") ?? "i1",
|
||||||
|
pageSize: string("page_size") ?? "A4",
|
||||||
|
bitDepth: int("bit_depth") ?? 8,
|
||||||
|
dpi: int("dpi") ?? 300,
|
||||||
|
randomSeed: int("random_seed"),
|
||||||
|
noRandomize: bool("no_randomize"),
|
||||||
|
calibrationFile: string("calibration_file"),
|
||||||
|
applyCalibration: bool("apply_calibration"),
|
||||||
|
colprofAlgorithm: string("colprof_algorithm"),
|
||||||
|
colprofQuality: string("colprof_quality"),
|
||||||
|
colprofIntent: string("colprof_intent"),
|
||||||
|
colprofFwa: string("colprof_fwa"),
|
||||||
|
colprofIlluminant: string("colprof_illuminant"),
|
||||||
|
colprofObserver: string("colprof_observer"),
|
||||||
|
colprofInputViewingCond: string("colprof_input_viewing_cond"),
|
||||||
|
colprofOutputViewingCond: string("colprof_output_viewing_cond")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Built-in presets shipped with the app (docs/22 §Built-in presets).
|
||||||
|
/// All four: instrument `i1`, FWA `D50`, random seed `1`,
|
||||||
|
/// `no_randomize == false`, colprof algorithm `l`.
|
||||||
|
///
|
||||||
|
/// Built-ins cannot be deleted; custom presets overlay by `id`.
|
||||||
|
public enum PresetCatalog {
|
||||||
|
|
||||||
|
/// `preset-std-rgb` — Standard RGB Photo (800 patches).
|
||||||
|
public static let standardRGB = ProfilingPreset(
|
||||||
|
id: "preset-std-rgb",
|
||||||
|
name: "Standard RGB Photo (800 patches)",
|
||||||
|
description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 800,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A4",
|
||||||
|
bitDepth: 8,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "m",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-hq-cmyk` — High-Gamut CMYK Proofing (1500 patches).
|
||||||
|
public static let highQualityCMYK = ProfilingPreset(
|
||||||
|
id: "preset-hq-cmyk",
|
||||||
|
name: "High-Gamut CMYK Proofing (1500 patches)",
|
||||||
|
description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.",
|
||||||
|
colourSpace: "cmyk",
|
||||||
|
patchCount: 1500,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 8,
|
||||||
|
totalInkLimit: 320,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A3",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "h",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-draft-rgb` — Fast RGB Draft (400 patches, **150 dpi**).
|
||||||
|
public static let draftRGB = ProfilingPreset(
|
||||||
|
id: "preset-draft-rgb",
|
||||||
|
name: "Fast RGB Draft (400 patches)",
|
||||||
|
description: "Quick sanity check — 400 patches on A4 at 150 dpi.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 400,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A4",
|
||||||
|
bitDepth: 8,
|
||||||
|
dpi: 150,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "l",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-ultra-rgb` — Ultra Precision RGB (2500 patches, `-G`).
|
||||||
|
public static let ultraRGB = ProfilingPreset(
|
||||||
|
id: "preset-ultra-rgb",
|
||||||
|
name: "Ultra Precision RGB (2500 patches)",
|
||||||
|
description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 2500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 6,
|
||||||
|
ofpsHighQuality: true,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A3",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "u",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
public static let builtIns: [ProfilingPreset] = [
|
||||||
|
standardRGB, highQualityCMYK, draftRGB, ultraRGB,
|
||||||
|
]
|
||||||
|
|
||||||
|
public static let builtInIDs: Set<String> = Set(builtIns.map(\.id))
|
||||||
|
|
||||||
|
public static func isBuiltIn(_ id: String) -> Bool {
|
||||||
|
builtInIDs.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-ins plus custom presets, with custom entries overlaying by
|
||||||
|
/// `id` (a custom preset with a built-in id replaces that entry in
|
||||||
|
/// place — the built-in is still not deletable).
|
||||||
|
public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] {
|
||||||
|
var result = builtIns
|
||||||
|
var seen = builtInIDs
|
||||||
|
for custom in custom {
|
||||||
|
if let idx = result.firstIndex(where: { $0.id == custom.id }) {
|
||||||
|
result[idx] = custom
|
||||||
|
} else if !seen.contains(custom.id) {
|
||||||
|
result.append(custom)
|
||||||
|
seen.insert(custom.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CRUD + import/export for profiling presets on top of `SettingsStore`
|
||||||
|
/// (docs/22 §Built-in presets, issue #11).
|
||||||
|
///
|
||||||
|
/// - `all()` = built-ins overlaid by custom presets (by `id`).
|
||||||
|
/// - Built-ins are never written to `settings.json` and cannot be
|
||||||
|
/// deleted or overwritten by `saveCustom` (a custom id that collides
|
||||||
|
/// with a built-in still overlays at read time, per spec).
|
||||||
|
/// - Import/export is single-preset JSON with schema validation.
|
||||||
|
/// - Imported names/descriptions are untrusted: callers must render
|
||||||
|
/// them with `Text`, never HTML (#114).
|
||||||
|
public final class PresetStore: Sendable {
|
||||||
|
|
||||||
|
public let settingsStore: SettingsStore
|
||||||
|
|
||||||
|
public init(settingsStore: SettingsStore = SettingsStore()) {
|
||||||
|
self.settingsStore = settingsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All presets: built-ins overlaid by customs, catalog order.
|
||||||
|
public func all() -> [ProfilingPreset] {
|
||||||
|
PresetCatalog.all(custom: settingsStore.load().customPresets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom presets only, as persisted.
|
||||||
|
public func customs() -> [ProfilingPreset] {
|
||||||
|
settingsStore.load().customPresets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace a custom preset (matched by `id`). Throws
|
||||||
|
/// `PresetStoreError.builtIn` when the id belongs to a built-in —
|
||||||
|
/// built-ins are immutable. Validates before persisting.
|
||||||
|
public func saveCustom(_ preset: ProfilingPreset) throws {
|
||||||
|
let validated = try preset.validated()
|
||||||
|
guard !PresetCatalog.isBuiltIn(validated.id) else {
|
||||||
|
throw PresetStoreError.builtInImmutable(validated.id)
|
||||||
|
}
|
||||||
|
var settings = settingsStore.load()
|
||||||
|
if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) {
|
||||||
|
settings.customPresets[idx] = validated
|
||||||
|
} else {
|
||||||
|
settings.customPresets.append(validated)
|
||||||
|
}
|
||||||
|
try settingsStore.save(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a custom preset by id. Returns false when the id is a
|
||||||
|
/// built-in (undeletable) or no custom preset with that id exists.
|
||||||
|
@discardableResult
|
||||||
|
public func deleteCustom(id: String) throws -> Bool {
|
||||||
|
guard !PresetCatalog.isBuiltIn(id) else { return false }
|
||||||
|
var settings = settingsStore.load()
|
||||||
|
let before = settings.customPresets.count
|
||||||
|
settings.customPresets.removeAll { $0.id == id }
|
||||||
|
guard settings.customPresets.count != before else { return false }
|
||||||
|
try settingsStore.save(settings)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-preset pretty JSON export.
|
||||||
|
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return try encoder.encode(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||||
|
/// fresh custom id when its id is empty or collides with a built-in.
|
||||||
|
/// Does **not** persist — call `saveCustom` to keep it.
|
||||||
|
public func `import`(_ data: Data) throws -> ProfilingPreset {
|
||||||
|
let decoded: ProfilingPreset
|
||||||
|
do {
|
||||||
|
decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw PresetStoreError.invalidJSON(error.localizedDescription)
|
||||||
|
}
|
||||||
|
var preset = try decoded.validated()
|
||||||
|
if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) {
|
||||||
|
preset.id = "custom-\(UUID().uuidString.lowercased())"
|
||||||
|
}
|
||||||
|
return preset
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PresetStoreError: LocalizedError, Equatable {
|
||||||
|
case builtInImmutable(String)
|
||||||
|
case invalidJSON(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .builtInImmutable(let id):
|
||||||
|
return "Built-in preset \"\(id)\" cannot be modified or deleted."
|
||||||
|
case .invalidJSON(let reason):
|
||||||
|
return "Not a valid preset file: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A profiling preset: a complete snapshot of the Stage 1/2 form plus
|
||||||
|
/// the Stage 4 fields that are stored now and applied in issue 23
|
||||||
|
/// (docs/22 §ProfilingPreset).
|
||||||
|
///
|
||||||
|
/// snake_case keys match the v1 JSON schema so import/export stays
|
||||||
|
/// compatible. Identity + Stage 1/2 core fields are required; every
|
||||||
|
/// other field is optional-defaulted. Unknown keys are ignored on
|
||||||
|
/// decode; missing required fields fail.
|
||||||
|
public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
public var description: String
|
||||||
|
|
||||||
|
// Stage 1 (required core)
|
||||||
|
public var colourSpace: String // "rgb" | "cmyk"
|
||||||
|
public var patchCount: Int
|
||||||
|
public var whitePatches: Int
|
||||||
|
public var blackPatches: Int
|
||||||
|
|
||||||
|
// Stage 1 advanced (optional)
|
||||||
|
public var greySteps: Int?
|
||||||
|
public var singleChannelSteps: Int?
|
||||||
|
public var neutralSteps: Int?
|
||||||
|
public var neutralConcentration: Double?
|
||||||
|
public var preconditioningProfile: String?
|
||||||
|
public var ofpsHighQuality: Bool?
|
||||||
|
public var ofpsAdaptation: Double?
|
||||||
|
/// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I".
|
||||||
|
public var fullSpreadAlgorithm: String?
|
||||||
|
public var totalInkLimit: Int?
|
||||||
|
public var darkEmphasis: Double?
|
||||||
|
public var devicePower: Double?
|
||||||
|
|
||||||
|
// Stage 2 (required core)
|
||||||
|
public var instrument: String
|
||||||
|
public var pageSize: String
|
||||||
|
public var bitDepth: Int
|
||||||
|
public var dpi: Int
|
||||||
|
public var randomSeed: Int?
|
||||||
|
public var noRandomize: Bool?
|
||||||
|
|
||||||
|
// Stage 0 / 2 calibration
|
||||||
|
public var calibrationFile: String?
|
||||||
|
public var applyCalibration: Bool?
|
||||||
|
|
||||||
|
// Stage 4 (stored now, applied by issue 23)
|
||||||
|
public var colprofAlgorithm: String?
|
||||||
|
public var colprofQuality: String?
|
||||||
|
public var colprofIntent: String?
|
||||||
|
public var colprofFwa: String?
|
||||||
|
public var colprofIlluminant: String?
|
||||||
|
public var colprofObserver: String?
|
||||||
|
public var colprofInputViewingCond: String?
|
||||||
|
public var colprofOutputViewingCond: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String = "",
|
||||||
|
colourSpace: String = "rgb",
|
||||||
|
patchCount: Int = 800,
|
||||||
|
whitePatches: Int = 4,
|
||||||
|
blackPatches: Int = 4,
|
||||||
|
greySteps: Int? = nil,
|
||||||
|
singleChannelSteps: Int? = nil,
|
||||||
|
neutralSteps: Int? = nil,
|
||||||
|
neutralConcentration: Double? = nil,
|
||||||
|
preconditioningProfile: String? = nil,
|
||||||
|
ofpsHighQuality: Bool? = nil,
|
||||||
|
ofpsAdaptation: Double? = nil,
|
||||||
|
fullSpreadAlgorithm: String? = nil,
|
||||||
|
totalInkLimit: Int? = nil,
|
||||||
|
darkEmphasis: Double? = nil,
|
||||||
|
devicePower: Double? = nil,
|
||||||
|
instrument: String = "i1",
|
||||||
|
pageSize: String = "A4",
|
||||||
|
bitDepth: Int = 8,
|
||||||
|
dpi: Int = 300,
|
||||||
|
randomSeed: Int? = 1,
|
||||||
|
noRandomize: Bool? = false,
|
||||||
|
calibrationFile: String? = nil,
|
||||||
|
applyCalibration: Bool? = nil,
|
||||||
|
colprofAlgorithm: String? = nil,
|
||||||
|
colprofQuality: String? = nil,
|
||||||
|
colprofIntent: String? = nil,
|
||||||
|
colprofFwa: String? = nil,
|
||||||
|
colprofIlluminant: String? = nil,
|
||||||
|
colprofObserver: String? = nil,
|
||||||
|
colprofInputViewingCond: String? = nil,
|
||||||
|
colprofOutputViewingCond: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.whitePatches = whitePatches
|
||||||
|
self.blackPatches = blackPatches
|
||||||
|
self.greySteps = greySteps
|
||||||
|
self.singleChannelSteps = singleChannelSteps
|
||||||
|
self.neutralSteps = neutralSteps
|
||||||
|
self.neutralConcentration = neutralConcentration
|
||||||
|
self.preconditioningProfile = preconditioningProfile
|
||||||
|
self.ofpsHighQuality = ofpsHighQuality
|
||||||
|
self.ofpsAdaptation = ofpsAdaptation
|
||||||
|
self.fullSpreadAlgorithm = fullSpreadAlgorithm
|
||||||
|
self.totalInkLimit = totalInkLimit
|
||||||
|
self.darkEmphasis = darkEmphasis
|
||||||
|
self.devicePower = devicePower
|
||||||
|
self.instrument = instrument
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.bitDepth = bitDepth
|
||||||
|
self.dpi = dpi
|
||||||
|
self.randomSeed = randomSeed
|
||||||
|
self.noRandomize = noRandomize
|
||||||
|
self.calibrationFile = calibrationFile
|
||||||
|
self.applyCalibration = applyCalibration
|
||||||
|
self.colprofAlgorithm = colprofAlgorithm
|
||||||
|
self.colprofQuality = colprofQuality
|
||||||
|
self.colprofIntent = colprofIntent
|
||||||
|
self.colprofFwa = colprofFwa
|
||||||
|
self.colprofIlluminant = colprofIlluminant
|
||||||
|
self.colprofObserver = colprofObserver
|
||||||
|
self.colprofInputViewingCond = colprofInputViewingCond
|
||||||
|
self.colprofOutputViewingCond = colprofOutputViewingCond
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description
|
||||||
|
case colourSpace = "colour_space"
|
||||||
|
case patchCount = "patch_count"
|
||||||
|
case whitePatches = "white_patches"
|
||||||
|
case blackPatches = "black_patches"
|
||||||
|
case greySteps = "grey_steps"
|
||||||
|
case singleChannelSteps = "single_channel_steps"
|
||||||
|
case neutralSteps = "neutral_steps"
|
||||||
|
case neutralConcentration = "neutral_concentration"
|
||||||
|
case preconditioningProfile = "preconditioning_profile"
|
||||||
|
case ofpsHighQuality = "ofps_high_quality"
|
||||||
|
case ofpsAdaptation = "ofps_adaptation"
|
||||||
|
case fullSpreadAlgorithm = "full_spread_algorithm"
|
||||||
|
case totalInkLimit = "total_ink_limit"
|
||||||
|
case darkEmphasis = "dark_emphasis"
|
||||||
|
case devicePower = "device_power"
|
||||||
|
case instrument
|
||||||
|
case pageSize = "page_size"
|
||||||
|
case bitDepth = "bit_depth"
|
||||||
|
case dpi
|
||||||
|
case randomSeed = "random_seed"
|
||||||
|
case noRandomize = "no_randomize"
|
||||||
|
case calibrationFile = "calibration_file"
|
||||||
|
case applyCalibration = "apply_calibration"
|
||||||
|
case colprofAlgorithm = "colprof_algorithm"
|
||||||
|
case colprofQuality = "colprof_quality"
|
||||||
|
case colprofIntent = "colprof_intent"
|
||||||
|
case colprofFwa = "colprof_fwa"
|
||||||
|
case colprofIlluminant = "colprof_illuminant"
|
||||||
|
case colprofObserver = "colprof_observer"
|
||||||
|
case colprofInputViewingCond = "colprof_input_viewing_cond"
|
||||||
|
case colprofOutputViewingCond = "colprof_output_viewing_cond"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: required identity + Stage 1/2 core fields must be
|
||||||
|
/// present; optionals default to nil. Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||||
|
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||||
|
patchCount = try c.decode(Int.self, forKey: .patchCount)
|
||||||
|
whitePatches = try c.decode(Int.self, forKey: .whitePatches)
|
||||||
|
blackPatches = try c.decode(Int.self, forKey: .blackPatches)
|
||||||
|
greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps)
|
||||||
|
singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps)
|
||||||
|
neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps)
|
||||||
|
neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration)
|
||||||
|
preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile)
|
||||||
|
ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality)
|
||||||
|
ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation)
|
||||||
|
fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm)
|
||||||
|
totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit)
|
||||||
|
darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis)
|
||||||
|
devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower)
|
||||||
|
instrument = try c.decode(String.self, forKey: .instrument)
|
||||||
|
pageSize = try c.decode(String.self, forKey: .pageSize)
|
||||||
|
bitDepth = try c.decode(Int.self, forKey: .bitDepth)
|
||||||
|
dpi = try c.decode(Int.self, forKey: .dpi)
|
||||||
|
randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed)
|
||||||
|
noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize)
|
||||||
|
calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile)
|
||||||
|
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration)
|
||||||
|
colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm)
|
||||||
|
colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality)
|
||||||
|
colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent)
|
||||||
|
colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa)
|
||||||
|
colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant)
|
||||||
|
colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver)
|
||||||
|
colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond)
|
||||||
|
colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation (import path)
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case emptyID
|
||||||
|
case emptyName
|
||||||
|
case invalidColourSpace(String)
|
||||||
|
case invalidPatchCount(Int)
|
||||||
|
case invalidBitDepth(Int)
|
||||||
|
case invalidDPI(Int)
|
||||||
|
case emptyPageSize
|
||||||
|
case emptyInstrument
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyID: return "Preset is missing an id."
|
||||||
|
case .emptyName: return "Preset is missing a name."
|
||||||
|
case .invalidColourSpace(let v):
|
||||||
|
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||||
|
case .invalidPatchCount(let v):
|
||||||
|
return "patch_count must be positive, got \(v)."
|
||||||
|
case .invalidBitDepth(let v):
|
||||||
|
return "bit_depth must be 8 or 16, got \(v)."
|
||||||
|
case .invalidDPI(let v):
|
||||||
|
return "dpi must be between 72 and 600, got \(v)."
|
||||||
|
case .emptyPageSize: return "page_size is empty."
|
||||||
|
case .emptyInstrument: return "instrument is empty."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the required fields for import / catalog use.
|
||||||
|
/// `colourSpace` is normalized to lowercase before comparison.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> ProfilingPreset {
|
||||||
|
var p = self
|
||||||
|
p.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.colourSpace = colourSpace.lowercased()
|
||||||
|
guard !p.id.isEmpty else { throw ValidationError.emptyID }
|
||||||
|
guard !p.name.isEmpty else { throw ValidationError.emptyName }
|
||||||
|
guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else {
|
||||||
|
throw ValidationError.invalidColourSpace(colourSpace)
|
||||||
|
}
|
||||||
|
guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) }
|
||||||
|
guard p.bitDepth == 8 || p.bitDepth == 16 else {
|
||||||
|
throw ValidationError.invalidBitDepth(bitDepth)
|
||||||
|
}
|
||||||
|
guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) }
|
||||||
|
guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw ValidationError.emptyPageSize
|
||||||
|
}
|
||||||
|
guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw ValidationError.emptyInstrument
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-element non-throwing decode wrapper — one malformed preset entry
|
||||||
|
/// must not drop the whole `custom_presets` array during migration.
|
||||||
|
struct FailableDecodable<T: Decodable>: Decodable {
|
||||||
|
let value: T?
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
value = try? T(from: decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persists `AppSettings` to
|
||||||
|
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||||
|
/// (issue #5 — the v1 path is never read).
|
||||||
|
///
|
||||||
|
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||||
|
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||||
|
/// reclassify swatches.
|
||||||
|
public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
|
/// Posted on `NotificationCenter.default` after every successful save.
|
||||||
|
public static let settingsDidChange =
|
||||||
|
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||||
|
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() -> AppSettings {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
|
else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates before persisting — throws `SettingsError` listing
|
||||||
|
/// every violation; nothing is written on failure.
|
||||||
|
public func save(_ settings: AppSettings) throws {
|
||||||
|
let errors = settings.validate()
|
||||||
|
guard errors.isEmpty else {
|
||||||
|
throw SettingsError.validationFailed(errors)
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||||
|
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SettingsError: Error, Equatable {
|
||||||
|
case validationFailed([String])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
|
||||||
|
///
|
||||||
|
/// Navigation is *disk*, not buttons: a stage unlocks only when its
|
||||||
|
/// predecessor artefacts exist. Forward moves are gated; backward is
|
||||||
|
/// always allowed. Gating is re-evaluated on window focus and on stage
|
||||||
|
/// entry (#151 — files can disappear in Finder).
|
||||||
|
public enum WizardGating {
|
||||||
|
|
||||||
|
/// Whether `stage` is reachable given the probed artefacts.
|
||||||
|
///
|
||||||
|
/// - Stage 0 (calibrate): always — it is out-of-band, not gated.
|
||||||
|
/// - Stage 1: always.
|
||||||
|
/// - Stage 2: `.ti1` exists.
|
||||||
|
/// - Stage 3: `.ti1` **and** `.ti2`.
|
||||||
|
/// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2`
|
||||||
|
/// alone never unlocks it; #109/#110).
|
||||||
|
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
|
||||||
|
public static func isUnlocked(
|
||||||
|
_ stage: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
switch stage {
|
||||||
|
case .calibrate: return true
|
||||||
|
case .generate: return true
|
||||||
|
case .layOutPrint: return artefacts.stage1Complete
|
||||||
|
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
|
||||||
|
case .buildProfile: return artefacts.stage3Complete
|
||||||
|
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `go(to:)` may proceed. Backward moves and the current
|
||||||
|
/// stage are always allowed; forward moves must be unlocked.
|
||||||
|
public static func canNavigate(
|
||||||
|
to target: WizardStage,
|
||||||
|
from current: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
if target == current { return true }
|
||||||
|
if target == .calibrate || current == .calibrate {
|
||||||
|
// Stage 0 is a side-trip, not stepper navigation.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if target.rawValue < current.rawValue { return true }
|
||||||
|
return isUnlocked(target, artefacts: artefacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deepest unlocked stepper stage — used when revalidation
|
||||||
|
/// locks the current stage (#151).
|
||||||
|
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
|
||||||
|
for stage in WizardStage.stepperStages.reversed()
|
||||||
|
where isUnlocked(stage, artefacts: artefacts) {
|
||||||
|
return stage
|
||||||
|
}
|
||||||
|
return .generate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Session mode (docs/06 §wizardState). `"calibration"` is set while
|
||||||
|
/// Stage 0 is driving a `CAL_` chart through the same pipeline.
|
||||||
|
public enum SessionMode: String, Codable, Sendable {
|
||||||
|
case profile
|
||||||
|
case calibration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted wizard state (docs/06 §wizardState fields) —
|
||||||
|
/// `wizard_state.json` in app data.
|
||||||
|
public struct WizardState: Codable, Equatable, Sendable {
|
||||||
|
/// 0–5 (`WizardStage.rawValue`).
|
||||||
|
public var currentStage: Int
|
||||||
|
/// Run name without extension — never invented (#60).
|
||||||
|
public var basename: String
|
||||||
|
/// Working directory for artefacts; empty → `resolveSafeCwd` (#59).
|
||||||
|
public var cwd: String
|
||||||
|
/// Last spooled printer, for calibration drift history.
|
||||||
|
public var printerName: String?
|
||||||
|
public var sessionMode: SessionMode
|
||||||
|
/// May differ from `basename` after a `.ti3` import (#94).
|
||||||
|
public var profileBasename: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
currentStage: Int = WizardStage.generate.rawValue,
|
||||||
|
basename: String = "",
|
||||||
|
cwd: String = "",
|
||||||
|
printerName: String? = nil,
|
||||||
|
sessionMode: SessionMode = .profile,
|
||||||
|
profileBasename: String? = nil
|
||||||
|
) {
|
||||||
|
self.currentStage = currentStage
|
||||||
|
self.basename = basename
|
||||||
|
self.cwd = cwd
|
||||||
|
self.printerName = printerName
|
||||||
|
self.sessionMode = sessionMode
|
||||||
|
self.profileBasename = profileBasename
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = WizardState()
|
||||||
|
|
||||||
|
/// The stage a saved `currentStage` resolves to, clamped to a valid
|
||||||
|
/// value (corrupt ints fall back to Stage 1).
|
||||||
|
public var stage: WizardStage {
|
||||||
|
WizardStage(rawValue: currentStage) ?? .generate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic JSON persistence for `WizardState` (issue #4).
|
||||||
|
public final class WizardStateStore: Sendable {
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileURL: URL = AppPaths.appDataDir.appendingPathComponent("wizard_state.json")
|
||||||
|
) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() -> WizardState {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let state = try? JSONDecoder().decode(WizardState.self, from: data)
|
||||||
|
else { return .default }
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ state: WizardState) throws {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try AtomicFileWriter.write(encoder.encode(state), to: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +1,79 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mock script for chartread -u
|
# Mock chartread for bundled/manual testing.
|
||||||
# This script simulates the behaviour of chartread for testing purposes.
|
# Supports handheld and XY modes. Writes basename.ti3 on 'd'.
|
||||||
|
MODE="${MOCK_CHARTREAD_MODE:-strip}"
|
||||||
|
BASENAME=""
|
||||||
|
|
||||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
# Basename is the last non-flag argument.
|
||||||
IS_XY=0
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$arg" = "--xy" ]; then
|
case "$arg" in
|
||||||
IS_XY=1
|
-*) ;;
|
||||||
break
|
*) BASENAME="$arg" ;;
|
||||||
fi
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
read_input() {
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
IFS= read -r line || return 1
|
||||||
read -r _calib
|
}
|
||||||
echo "Calibration successful."
|
|
||||||
|
|
||||||
echo "Please place sheet 1 of 1 on the table"
|
emit_row() {
|
||||||
echo "hit return to continue, Esc or 'q' to give up"
|
printf 'ROW_COLORS_JSON: %s\n' "$1"
|
||||||
read -r _sheet1
|
}
|
||||||
|
|
||||||
echo "locate patch A1 with the sight,"
|
write_ti3() {
|
||||||
echo "then hit return to continue"
|
if [ -n "$BASENAME" ]; then
|
||||||
read -r _fid1
|
echo "MOCK_TI3" > "${BASENAME}.ti3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "locate patch B24 with the sight,"
|
if [ "$MODE" = "xy" ]; then
|
||||||
echo "then hit return to continue"
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
read -r _fid2
|
read_input
|
||||||
|
echo "Calibration successful."
|
||||||
|
|
||||||
echo "Reading sheet 1..."
|
echo "Please place sheet 1 of 1 on the table"
|
||||||
sleep 0.5
|
echo "hit return to continue, Esc or 'q' to give up"
|
||||||
|
read_input
|
||||||
|
|
||||||
# Emit mock JSON for strip A
|
echo "locate patch A1 with the sight,"
|
||||||
cat << 'EOF'
|
echo "then hit return to continue"
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
read_input
|
||||||
EOF
|
|
||||||
|
|
||||||
# Emit mock JSON for strip B
|
echo "Reading sheet 1..."
|
||||||
cat << 'EOF'
|
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Sheet 1 of 1 read OK"
|
echo "Sheet 1 of 1 read OK"
|
||||||
echo "Please remove last sheet from table"
|
echo "Please remove last sheet from table"
|
||||||
exit 0
|
echo "'d' if/when done"
|
||||||
|
while read_input; do
|
||||||
|
case "$line" in
|
||||||
|
d*) write_ti3; exit 0 ;;
|
||||||
|
q*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handheld / strip reader simulation
|
# Handheld / strip mode (default)
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
read_input
|
||||||
# We don't really wait for input, just wait 1 second
|
|
||||||
sleep 1
|
|
||||||
echo "Calibration successful."
|
echo "Calibration successful."
|
||||||
echo "Hit [Space] to read strip A (or 's' to skip)."
|
|
||||||
|
|
||||||
sleep 1
|
echo "Hit [Space] to read strip A"
|
||||||
|
read_input
|
||||||
echo "Reading strip A..."
|
echo "Reading strip A..."
|
||||||
|
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||||
|
|
||||||
# Emit mock JSON for strip A
|
echo "Hit [Space] to read strip B"
|
||||||
cat << 'EOF'
|
read_input
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Hit [Space] to read strip B (or 's' to skip)."
|
|
||||||
sleep 1
|
|
||||||
echo "Reading strip B..."
|
echo "Reading strip B..."
|
||||||
|
emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}'
|
||||||
|
|
||||||
# Emit mock JSON for strip B
|
echo "'d' if/when done"
|
||||||
cat << 'EOF'
|
while read_input; do
|
||||||
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
case "$line" in
|
||||||
EOF
|
d*) write_ti3; exit 0 ;;
|
||||||
|
q*) exit 0 ;;
|
||||||
echo "Ready to read... done."
|
esac
|
||||||
|
done
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// About dialog for ICCery (issue #31, docs/21 §Modals).
|
||||||
|
struct AboutView: View {
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image("ICCery-logo")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 64)
|
||||||
|
|
||||||
|
Text("ICCery")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Version:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.version)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutVersion")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Build:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.build)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Build date:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.buildDate)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutBuildDate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
Text("Native macOS printer profiling workstation.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("Close") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("closeAboutBtn")
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 360)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// App dependency container (docs/02). Production builds resolve the
|
||||||
|
/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI
|
||||||
|
/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and
|
||||||
|
/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the
|
||||||
|
/// developer's settings, wizard state, or real Argyll install.
|
||||||
|
struct AppEnvironment: Sendable {
|
||||||
|
let stateStore: WizardStateStore
|
||||||
|
let settingsStore: SettingsStore
|
||||||
|
let presetStore: PresetStore
|
||||||
|
let runner: ArgyllRunner
|
||||||
|
let cupsService: CupsService
|
||||||
|
let historyStore: VerificationHistoryStore
|
||||||
|
|
||||||
|
static func live(
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
|
) -> AppEnvironment {
|
||||||
|
let settingsStore = SettingsStore()
|
||||||
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
|
#if DEBUG
|
||||||
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
|
overrideDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return AppEnvironment(
|
||||||
|
stateStore: WizardStateStore(),
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
|
runner: ArgyllRunner(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
||||||
|
),
|
||||||
|
cupsService: CupsService(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryDir: cupsDir),
|
||||||
|
historyStore: VerificationHistoryStore()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow
|
||||||
|
/// honours these env-provided paths instead of presenting modal panels
|
||||||
|
/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are
|
||||||
|
/// compiled out of release builds.
|
||||||
|
enum UITestHooks {
|
||||||
|
private static var env: [String: String] {
|
||||||
|
ProcessInfo.processInfo.environment
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isEnabled: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return env["ICCERY_UI_TESTING"] == "1"
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `select_target_file` result (Stage 1 save picker).
|
||||||
|
static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") }
|
||||||
|
/// `select_existing_target` result (`.ti1`/`.ti2` resume).
|
||||||
|
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
||||||
|
/// `select_directory` result (working-directory browse).
|
||||||
|
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
||||||
|
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
|
||||||
|
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
|
||||||
|
/// Preset import file.
|
||||||
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
|
/// Preset export destination.
|
||||||
|
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||||
|
|
||||||
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
||||||
|
/// `CupsService.binaryDir` under UI tests.
|
||||||
|
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||||
|
|
||||||
|
/// Path the mock `lp` script appends its argv to, for assertions.
|
||||||
|
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
||||||
|
|
||||||
|
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||||
|
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||||
|
/// fall through to the real modal.
|
||||||
|
static var printPanelStubbed: Bool { isEnabled }
|
||||||
|
|
||||||
|
/// Canned `NSPrintPanel` outcome — XCUITest cannot drive the
|
||||||
|
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
|
||||||
|
/// - `cancel` (or unset while testing) → user cancelled → `nil`
|
||||||
|
/// - `ok` → `PrintPropertiesResult` with
|
||||||
|
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
||||||
|
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
||||||
|
/// queue the panel was opened for).
|
||||||
|
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
|
||||||
|
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
||||||
|
case "ok":
|
||||||
|
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
return PrintPropertiesResult(
|
||||||
|
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
} ?? queue,
|
||||||
|
options: PrintOptions(
|
||||||
|
mediaType: options.flatMap {
|
||||||
|
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||||
|
},
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: options))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func url(_ key: String) -> URL? {
|
||||||
|
guard let raw = env[key], !raw.isEmpty else { return nil }
|
||||||
|
return URL(fileURLWithPath: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Minimal line chart for drift history without depending on the
|
||||||
|
/// `Charts` framework link. Renders avg/max series with shaded quality
|
||||||
|
/// bands.
|
||||||
|
struct DriftChartView: View {
|
||||||
|
let records: [VerificationRecord]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = geometry.size.width
|
||||||
|
let height = geometry.size.height
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if let (_, _, _, maxV) = scales(in: height) {
|
||||||
|
// Quality bands — bottom (red, > 3.5) drawn first, then
|
||||||
|
// orange, yellow, green so the upper-most bands overlay.
|
||||||
|
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
|
||||||
|
// Average ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.avgDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftAvgSeries")
|
||||||
|
|
||||||
|
// Max ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.maxDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.orange, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftMaxSeries")
|
||||||
|
} else {
|
||||||
|
Text("No data")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func band(
|
||||||
|
from lower: Double,
|
||||||
|
to upper: Double,
|
||||||
|
color: Color,
|
||||||
|
height: CGFloat,
|
||||||
|
maxValue: Double
|
||||||
|
) -> some View {
|
||||||
|
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
return color
|
||||||
|
.frame(height: yBottom - yTop)
|
||||||
|
.offset(y: yTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
|
||||||
|
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
|
||||||
|
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
|
||||||
|
return (minT, maxT, 0.0, maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func point(
|
||||||
|
for record: VerificationRecord,
|
||||||
|
minTime: Date,
|
||||||
|
maxTime: Date,
|
||||||
|
minValue: Double,
|
||||||
|
maxValue: Double,
|
||||||
|
width: CGFloat,
|
||||||
|
height: CGFloat,
|
||||||
|
keyPath: KeyPath<VerificationRecord, Double>
|
||||||
|
) -> CGPoint {
|
||||||
|
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
|
||||||
|
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
|
||||||
|
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
|
||||||
|
return CGPoint(x: x, y: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
|
||||||
|
let valueSpan = max(1, maxValue - minValue)
|
||||||
|
return height - height * CGFloat((value - minValue) / valueSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method
|
||||||
|
/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs).
|
||||||
|
/// No call site shares a generic picker (#103/#210/#211).
|
||||||
|
@MainActor
|
||||||
|
final class FileDialogService {
|
||||||
|
|
||||||
|
static let shared = FileDialogService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - selectDirectory
|
||||||
|
|
||||||
|
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||||
|
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||||
|
func selectDirectory(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
panel.prompt = "Choose"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dedicated open pickers
|
||||||
|
|
||||||
|
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||||
|
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "target.ti1"
|
||||||
|
panel.allowedContentTypes = utTypes(["ti1"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
panel.message = "Choose the .ti1 target file to create"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||||
|
func selectExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti1", "ti2"], startingAt: start,
|
||||||
|
message: "Open an existing target (.ti1 or .ti2)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*`
|
||||||
|
/// (#172: the profile filter must not accept datasets).
|
||||||
|
func selectProfileFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["icc", "icm", "mpp"], startingAt: start,
|
||||||
|
message: "Choose an ICC/ICM profile or measurement preconditioning file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i).
|
||||||
|
func selectSpectrumFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["sp"], startingAt: start,
|
||||||
|
message: "Choose a custom illuminant spectrum (.sp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`,
|
||||||
|
/// `.cgats`, `.csv`). Always an *open* dialog, never save (#211).
|
||||||
|
func selectDatasetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start,
|
||||||
|
message: "Import a measured dataset")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCsvSavePath` — verification-history CSV export.
|
||||||
|
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "verification-history.csv"
|
||||||
|
panel.allowedContentTypes = utTypes(["csv"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCalFile` — `.cal` calibration curves.
|
||||||
|
func selectCalFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["cal"], startingAt: start,
|
||||||
|
message: "Choose a calibration file (.cal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnImportPreset` — open a `.json` preset file.
|
||||||
|
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["json"], startingAt: start,
|
||||||
|
message: "Import a profiling preset (.json)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||||
|
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "\(name).json"
|
||||||
|
panel.allowedContentTypes = utTypes(["json"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
panel.message = "Export this preset as JSON"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
|
private func open(
|
||||||
|
extensions: [String],
|
||||||
|
startingAt start: URL?,
|
||||||
|
message: String?
|
||||||
|
) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.directoryURL = start
|
||||||
|
if let message { panel.message = message }
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func utTypes(_ extensions: [String]) -> [UTType] {
|
||||||
|
extensions.compactMap { UTType(filenameExtension: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSSavePanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable help overlay badge that does not reflow layout (#171).
|
||||||
|
///
|
||||||
|
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
|
||||||
|
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
|
||||||
|
/// available on hover, so the overlay is purely a visual cue in help mode.
|
||||||
|
struct HelpOverlay: ViewModifier {
|
||||||
|
let text: String
|
||||||
|
@Binding var showing: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.help(text)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if showing {
|
||||||
|
Image(systemName: "questionmark.circle.fill")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.offset(x: 8, y: -8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Adds a non-reflowing help overlay to the view.
|
||||||
|
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
|
||||||
|
modifier(HelpOverlay(text: text, showing: showing))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,21 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct ICCeryApp: App {
|
struct ICCeryApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var model = WizardViewModel()
|
@State private var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let environment = AppEnvironment.live()
|
||||||
|
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
|
||||||
|
try? AppPaths.ensureDirectories()
|
||||||
|
// Log level is runtime state — apply persisted settings at
|
||||||
|
// startup (#158); the Settings sheet re-applies on save.
|
||||||
|
LogSink.shared.applySettings(environment.settingsStore.load())
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||||
Window("ICCery", id: "main") {
|
Window("ICCery", id: "main") {
|
||||||
RootView(model: model)
|
RootView(workflow: workflow)
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// A single evaluated swatch for the live grid.
|
||||||
|
struct Swatch: Sendable, Equatable, Identifiable {
|
||||||
|
let rowId: String
|
||||||
|
let loc: String
|
||||||
|
let isPad: Bool
|
||||||
|
let intended: DisplayRGB
|
||||||
|
let measured: DisplayRGB
|
||||||
|
let deltaE: Double?
|
||||||
|
let classification: SwatchClassification
|
||||||
|
|
||||||
|
var id: String { "\(rowId)\(loc)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of swatches in display order.
|
||||||
|
struct SwatchRow: Sendable, Equatable, Identifiable {
|
||||||
|
let index: Int
|
||||||
|
let rowId: String
|
||||||
|
let patches: [Swatch]
|
||||||
|
|
||||||
|
var id: String { rowId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage of the XY-table badge bar.
|
||||||
|
enum XYStep: Equatable, Sendable {
|
||||||
|
case place, align, scan, remove
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MeasurementWorkflowViewModel {
|
||||||
|
|
||||||
|
// MARK: - Authorities
|
||||||
|
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
// MARK: - Settings-driven thresholds
|
||||||
|
|
||||||
|
private(set) var goodMax: Double = 2.0
|
||||||
|
private(set) var warningMax: Double = 5.0
|
||||||
|
private(set) var enableLEDs: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
|
var instruments: [InstrumentDevice] = []
|
||||||
|
var selectedInstrument: InstrumentSelection = .auto
|
||||||
|
var isDetecting = false
|
||||||
|
var detectionError: String?
|
||||||
|
|
||||||
|
// MARK: - Chartread session
|
||||||
|
|
||||||
|
var isChartreadRunning = false
|
||||||
|
var chartreadState: ChartreadState = .idle
|
||||||
|
var currentPrompt: String?
|
||||||
|
var requestedWarningKey: String?
|
||||||
|
var chartreadLog: [String] = []
|
||||||
|
var rows: [ChartreadRow] = []
|
||||||
|
var swatchRows: [SwatchRow] = []
|
||||||
|
var showRemoveSheetNotice = false
|
||||||
|
var lastError: String?
|
||||||
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// MARK: - Averaging
|
||||||
|
|
||||||
|
var passSnapshots: [URL] = []
|
||||||
|
var isFinishing = false
|
||||||
|
var finishNotice: String?
|
||||||
|
var finishNoticeIsError = false
|
||||||
|
var resumedFromTi2 = false
|
||||||
|
|
||||||
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
self.wizard = wizard
|
||||||
|
self.environment = environment
|
||||||
|
loadSettings()
|
||||||
|
discoverPassSnapshots()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
var basename: String { wizard.basename }
|
||||||
|
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
|
||||||
|
|
||||||
|
var canDetect: Bool { !isDetecting }
|
||||||
|
|
||||||
|
var canStartRead: Bool {
|
||||||
|
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var canMeasureAnotherSheet: Bool {
|
||||||
|
isFinished && !passSnapshots.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var canFinish: Bool {
|
||||||
|
isFinished && !passSnapshots.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFinished: Bool {
|
||||||
|
chartreadState == .allStripsRead || chartreadState == .finished
|
||||||
|
}
|
||||||
|
|
||||||
|
var xyStep: XYStep {
|
||||||
|
if showRemoveSheetNotice { return .remove }
|
||||||
|
switch chartreadState {
|
||||||
|
case .tablePlaceSheet:
|
||||||
|
return .place
|
||||||
|
case .tableAlign:
|
||||||
|
return .align
|
||||||
|
case .reading, .awaitingStrip:
|
||||||
|
return .scan
|
||||||
|
default:
|
||||||
|
return .place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCanonicalTi3: Bool {
|
||||||
|
guard let cwd = workingDirectory else { return false }
|
||||||
|
let url = cwd.appendingPathComponent("\(basename).ti3")
|
||||||
|
return FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
func loadSettings() {
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
goodMax = settings.deltaEGoodMax
|
||||||
|
warningMax = settings.deltaEWarningMax
|
||||||
|
enableLEDs = settings.enableI1Pro2Leds
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
|
func detectInstruments() {
|
||||||
|
guard !isDetecting else { return }
|
||||||
|
isDetecting = true
|
||||||
|
detectionError = nil
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let devices = try await self.environment.runner.detectInstruments()
|
||||||
|
self.instruments = devices
|
||||||
|
if case .device(let selected) = self.selectedInstrument,
|
||||||
|
!devices.contains(where: { $0.port == selected.port }) {
|
||||||
|
self.selectedInstrument = .auto
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.detectionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isDetecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chartread lifecycle
|
||||||
|
|
||||||
|
func startRead() {
|
||||||
|
guard canStartRead, let cwd = workingDirectory else { return }
|
||||||
|
let config = buildChartreadConfig(cwd: cwd)
|
||||||
|
startChartread(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureAnotherSheet() {
|
||||||
|
guard let cwd = workingDirectory, isFinished else { return }
|
||||||
|
let config = buildChartreadConfig(cwd: cwd)
|
||||||
|
startChartread(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
|
||||||
|
ChartreadConfig(
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
selectedPort: selectedInstrument.chartreadPort,
|
||||||
|
enableLEDs: enableLEDs,
|
||||||
|
isXY: selectedInstrument.isXY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startChartread(config: ChartreadConfig) {
|
||||||
|
guard !isChartreadRunning else { return }
|
||||||
|
|
||||||
|
isChartreadRunning = true
|
||||||
|
chartreadState = .idle
|
||||||
|
currentPrompt = nil
|
||||||
|
lastError = nil
|
||||||
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
|
if passSnapshots.isEmpty {
|
||||||
|
rows.removeAll()
|
||||||
|
swatchRows.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = environment.runner.runChartread(config: config)
|
||||||
|
|
||||||
|
chartreadTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for await event in stream {
|
||||||
|
self.handle(event: event)
|
||||||
|
}
|
||||||
|
self.isChartreadRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: ChartreadEvent) {
|
||||||
|
switch event {
|
||||||
|
case .prompt(let result):
|
||||||
|
chartreadState = result.state
|
||||||
|
currentPrompt = promptText(for: result)
|
||||||
|
requestedWarningKey = result.requestedWarningKey
|
||||||
|
showRemoveSheetNotice = result.isRemoveSheetNotice
|
||||||
|
|
||||||
|
case .row(let row):
|
||||||
|
upsert(row: row)
|
||||||
|
if row.isFinalRow {
|
||||||
|
chartreadState = .allStripsRead
|
||||||
|
}
|
||||||
|
|
||||||
|
case .log(let batch):
|
||||||
|
chartreadLog.append(contentsOf: batch)
|
||||||
|
|
||||||
|
case .removeSheetNotice:
|
||||||
|
showRemoveSheetNotice = true
|
||||||
|
|
||||||
|
case .exit(let code):
|
||||||
|
if code != 0 {
|
||||||
|
lastError = "chartread exited with code \(code)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .completed(let canonicalURL):
|
||||||
|
chartreadState = .finished
|
||||||
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
|
case .failed(let error):
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
chartreadState = .error
|
||||||
|
isChartreadRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptText(for result: ChartreadClassifyResult) -> String {
|
||||||
|
switch result.state {
|
||||||
|
case .calibrating:
|
||||||
|
return "Place instrument on calibration tile and press Calibrate."
|
||||||
|
case .awaitingStrip:
|
||||||
|
return "Press a key to read the next strip."
|
||||||
|
case .allStripsRead:
|
||||||
|
return "All strips read. Press Done & Save when ready."
|
||||||
|
case .warning:
|
||||||
|
if let key = result.requestedWarningKey {
|
||||||
|
return "Warning — press '\(key.uppercased())' to continue."
|
||||||
|
}
|
||||||
|
return "Warning — press Continue."
|
||||||
|
case .promptContinue:
|
||||||
|
return "Press Continue."
|
||||||
|
case .tablePlaceSheet:
|
||||||
|
if let n = result.sheetNumber, let t = result.sheetTotal {
|
||||||
|
return "Place sheet \(n) of \(t) on the table."
|
||||||
|
}
|
||||||
|
return "Place the sheet on the table."
|
||||||
|
case .tableAlign:
|
||||||
|
if let patch = result.alignmentPatch {
|
||||||
|
return "Locate patch \(patch) with the sight, then continue."
|
||||||
|
}
|
||||||
|
return "Align the fiducial, then continue."
|
||||||
|
case .reading:
|
||||||
|
return "Reading..."
|
||||||
|
case .error:
|
||||||
|
return "Read error — you can Retry or Cancel."
|
||||||
|
case .finished:
|
||||||
|
return "Measurement saved."
|
||||||
|
case .idle:
|
||||||
|
return "Press Start to begin reading."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User actions
|
||||||
|
|
||||||
|
func calibrate() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accept() {
|
||||||
|
if let key = requestedWarningKey {
|
||||||
|
send(.customKey(key))
|
||||||
|
requestedWarningKey = nil
|
||||||
|
} else {
|
||||||
|
send(.accept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retry() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doneAndSave() {
|
||||||
|
send(.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRead() {
|
||||||
|
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
|
||||||
|
chartreadTask?.cancel()
|
||||||
|
isChartreadRunning = false
|
||||||
|
chartreadState = .idle
|
||||||
|
currentPrompt = nil
|
||||||
|
requestedWarningKey = nil
|
||||||
|
showRemoveSheetNotice = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendWarningKey(_ key: String) {
|
||||||
|
send(.customKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ input: ChartreadInput) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.isChartreadRunning else { return }
|
||||||
|
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows and swatches
|
||||||
|
|
||||||
|
private func upsert(row: ChartreadRow) {
|
||||||
|
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
|
||||||
|
rows[index] = row
|
||||||
|
} else {
|
||||||
|
rows.append(row)
|
||||||
|
}
|
||||||
|
rows.sort { $0.rowIndex < $1.rowIndex }
|
||||||
|
recomputeSwatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recomputeSwatches() {
|
||||||
|
var displayRows: [SwatchRow] = []
|
||||||
|
for (rowIndex, row) in rows.enumerated() {
|
||||||
|
var swatches: [Swatch] = []
|
||||||
|
for patch in row.patches {
|
||||||
|
let skip = shouldSkipPad(patch)
|
||||||
|
if skip { continue }
|
||||||
|
|
||||||
|
let eval = ColorDifference.evaluate(
|
||||||
|
patch: patch,
|
||||||
|
goodMax: goodMax,
|
||||||
|
warningMax: warningMax
|
||||||
|
)
|
||||||
|
if let eval {
|
||||||
|
swatches.append(Swatch(
|
||||||
|
rowId: row.rowId,
|
||||||
|
loc: patch.loc,
|
||||||
|
isPad: patch.isPad,
|
||||||
|
intended: eval.intended,
|
||||||
|
measured: eval.measured,
|
||||||
|
deltaE: eval.deltaE,
|
||||||
|
classification: eval.classification
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !swatches.isEmpty {
|
||||||
|
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
swatchRows = displayRows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
|
||||||
|
guard patch.isPad else { return false }
|
||||||
|
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
|
||||||
|
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
|
||||||
|
return measuredEmpty && deviceAllZero
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pass management
|
||||||
|
|
||||||
|
private func completePass(canonicalURL: URL) {
|
||||||
|
guard let cwd = workingDirectory else { return }
|
||||||
|
do {
|
||||||
|
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
|
||||||
|
discoverPassSnapshots()
|
||||||
|
wizard.refreshGating()
|
||||||
|
} catch {
|
||||||
|
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverPassSnapshots() {
|
||||||
|
guard let cwd = workingDirectory else {
|
||||||
|
passSnapshots = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finish / Average
|
||||||
|
|
||||||
|
func finishAndAverage() {
|
||||||
|
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 }
|
||||||
|
do {
|
||||||
|
let canonical: URL
|
||||||
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
|
canonical = try MeasurementArtefacts.promotePass(
|
||||||
|
pass: pass,
|
||||||
|
basename: self.basename,
|
||||||
|
cwd: cwd
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let config = AverageConfig(
|
||||||
|
workingDirectory: cwd,
|
||||||
|
basename: self.basename,
|
||||||
|
passFiles: self.passSnapshots
|
||||||
|
)
|
||||||
|
canonical = try await self.environment.runner.runAverage(
|
||||||
|
config: config,
|
||||||
|
onLogBatch: { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.chartreadLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.discoverPassSnapshots()
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
if self.wizard.isUnlocked(.buildProfile) {
|
||||||
|
self.wizard.go(to: .buildProfile)
|
||||||
|
} else {
|
||||||
|
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to pass 1 promotion if averaging failed.
|
||||||
|
if let pass = self.passSnapshots.first {
|
||||||
|
do {
|
||||||
|
_ = try MeasurementArtefacts.promotePass(
|
||||||
|
pass: pass,
|
||||||
|
basename: self.basename,
|
||||||
|
cwd: cwd
|
||||||
|
)
|
||||||
|
self.discoverPassSnapshots()
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.finishNotice = "Averaging failed — promoted first pass."
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
} catch {
|
||||||
|
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.isFinishing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ struct NoticeBanner: View {
|
|||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(Theme.text)
|
.foregroundStyle(Theme.text)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
|
.accessibilityIdentifier("noticeText")
|
||||||
|
.accessibilityValue(notice.text)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: onClose) {
|
Button(action: onClose) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
||||||
|
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
||||||
|
struct SavePresetDialog: View {
|
||||||
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Save Preset").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
TextField("Name", text: $workflow.savePresetName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("savePresetName")
|
||||||
|
TextField("Description (optional)", text: $workflow.savePresetDesc)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("savePresetDesc")
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { workflow.showingSavePreset = false }
|
||||||
|
.accessibilityIdentifier("btnCloseSavePresetDialog")
|
||||||
|
Button("Save") { workflow.saveCurrentAsPreset() }
|
||||||
|
.accessibilityIdentifier("btnConfirmSavePreset")
|
||||||
|
.disabled(workflow.savePresetName
|
||||||
|
.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 380)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("savePresetDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
||||||
|
struct ManagePresetsDialog: View {
|
||||||
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Manage Presets").font(.title3).foregroundStyle(Theme.text)
|
||||||
|
List {
|
||||||
|
ForEach(workflow.presets) { preset in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(preset.name).foregroundStyle(Theme.text)
|
||||||
|
if !preset.description.isEmpty {
|
||||||
|
Text(preset.description)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if PresetCatalog.isBuiltIn(preset.id) {
|
||||||
|
Text("Built-in")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Button("Export") { workflow.exportPreset(preset) }
|
||||||
|
.accessibilityIdentifier(
|
||||||
|
"btnExportPreset-\(preset.id)")
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
workflow.deletePreset(preset)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnDeletePreset-\(preset.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("presetRow-\(preset.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("managePresetsList")
|
||||||
|
.frame(minHeight: 240)
|
||||||
|
HStack {
|
||||||
|
Button("Import…") { workflow.importPreset() }
|
||||||
|
.accessibilityIdentifier("btnImportPreset")
|
||||||
|
if let selected = workflow.selectedPreset,
|
||||||
|
!PresetCatalog.isBuiltIn(selected.id) {
|
||||||
|
Button("Export Active") { workflow.exportPreset(selected) }
|
||||||
|
.accessibilityIdentifier("btnExportActivePreset")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Close") { workflow.showingManagePresets = false }
|
||||||
|
.accessibilityIdentifier("btnCloseManagePresetsDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 480)
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("managePresetsDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||||
|
/// The second argument is the mode string — never integer `1` (#188).
|
||||||
|
typealias ColorMatchingModeFunction =
|
||||||
|
@convention(c) (PMPrintSession, CFString) -> OSStatus
|
||||||
|
|
||||||
|
/// `PMPrintSettingsToOptions` — public symbol, resolved via dlsym so a
|
||||||
|
/// missing SDK declaration can't break the build.
|
||||||
|
typealias PrintSettingsToOptionsFunction =
|
||||||
|
@convention(c) (PMPrintSettings, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) -> OSStatus
|
||||||
|
|
||||||
|
/// The six-layer unmanaged-printing engine (issue 14, docs/11):
|
||||||
|
///
|
||||||
|
/// ① session binding — done by `PrintPanelService` before calling us.
|
||||||
|
/// ② private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}` —
|
||||||
|
/// resolved by `dlsym(RTLD_DEFAULT,…)`; first `(symbol, mode)`
|
||||||
|
/// returning `0` wins.
|
||||||
|
/// ③ `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and
|
||||||
|
/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked.
|
||||||
|
/// ④ driver "no colour adjustment" pre-select from `lpoptions -l`
|
||||||
|
/// keys, unlocked (`detectDriverColorBypass`).
|
||||||
|
/// ⑤ mirror ③+④ into `NSPrintInfo.printSettings` so the PDE sees them.
|
||||||
|
/// ⑥ after "Use Settings": `PMPrintSettingsToOptions` →
|
||||||
|
/// `CupsOptionsFilter` → captured `cupsOptions` + `mediaType`.
|
||||||
|
///
|
||||||
|
/// All layers degrade gracefully — a missing symbol or non-zero status
|
||||||
|
/// is logged and the next layer still runs.
|
||||||
|
@MainActor
|
||||||
|
struct ColorSyncSuppressor {
|
||||||
|
|
||||||
|
/// Injected for tests: symbol → function. Default resolves via
|
||||||
|
/// `dlsym(RTLD_DEFAULT, …)`.
|
||||||
|
typealias ModeResolver = (String) -> ColorMatchingModeFunction?
|
||||||
|
typealias OptionsResolver = () -> PrintSettingsToOptionsFunction?
|
||||||
|
|
||||||
|
var modeResolver: ModeResolver = Self.dlsymMode
|
||||||
|
var optionsResolver: OptionsResolver = Self.dlsymOptions
|
||||||
|
var log: (String) -> Void = { AppLogger.shared.log(.info, $0) }
|
||||||
|
|
||||||
|
// MARK: - Layer ② SPI
|
||||||
|
|
||||||
|
/// Walk `ColorMatchingAttempts.attempts` (Lock → plain → NoLock ×
|
||||||
|
/// `AP_ApplicationColorMatching` → `ApplicationColorMatching`); the
|
||||||
|
/// first call returning `0` wins. `false` when nothing worked.
|
||||||
|
@discardableResult
|
||||||
|
func applySPIMode(to session: PMPrintSession) -> Bool {
|
||||||
|
for attempt in ColorMatchingAttempts.attempts {
|
||||||
|
guard let function = modeResolver(attempt.symbol) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let status = function(session, attempt.mode as CFString)
|
||||||
|
if status == 0 {
|
||||||
|
log("ColorSync: \(attempt.symbol) accepted "
|
||||||
|
+ "\(attempt.mode)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("ColorSync: no PMSessionSetColorMatchingMode* accepted a "
|
||||||
|
+ "mode — falling back to PMPrintSettingsSetValue")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ③ locked AP_* keys
|
||||||
|
|
||||||
|
/// `PMPrintSettingsSetValue` both key spellings, locked.
|
||||||
|
@discardableResult
|
||||||
|
func applyLockedKeys(to settings: PMPrintSettings) -> Int {
|
||||||
|
var applied = 0
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings,
|
||||||
|
key as CFString,
|
||||||
|
ColorMatchingAttempts.applicationMatchingValue as CFString,
|
||||||
|
true)
|
||||||
|
if status == 0 { applied += 1 }
|
||||||
|
}
|
||||||
|
if applied == 0 {
|
||||||
|
log("ColorSync: PMPrintSettingsSetValue could not lock "
|
||||||
|
+ "AP_ColorMatchingMode")
|
||||||
|
}
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ④ driver bypass
|
||||||
|
|
||||||
|
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
||||||
|
/// the PDE may override it. Returns the `(key, value)` applied.
|
||||||
|
@discardableResult
|
||||||
|
func applyDriverBypass(
|
||||||
|
to settings: PMPrintSettings,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) -> (key: String, value: String)? {
|
||||||
|
guard let bypass = CupsParsers.detectDriverColorBypass(
|
||||||
|
optionKeys: optionKeys)
|
||||||
|
else { return nil }
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings,
|
||||||
|
bypass.key as CFString,
|
||||||
|
bypass.value as CFString,
|
||||||
|
false)
|
||||||
|
if status != 0 {
|
||||||
|
log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ⑤ NSPrintInfo mirror
|
||||||
|
|
||||||
|
/// Mirror the applied keys into `printSettings` so the PDE pick
|
||||||
|
/// sees them.
|
||||||
|
func mirror(
|
||||||
|
into printInfo: NSPrintInfo,
|
||||||
|
driverBypass: (key: String, value: String)?
|
||||||
|
) {
|
||||||
|
let settings = printInfo.printSettings
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
||||||
|
}
|
||||||
|
if let driverBypass {
|
||||||
|
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ⑥ capture
|
||||||
|
|
||||||
|
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`.
|
||||||
|
/// The malloc'd C string is freed after copying.
|
||||||
|
func captureOptions(
|
||||||
|
from settings: PMPrintSettings
|
||||||
|
) -> (cupsOptions: String?, mediaType: String?) {
|
||||||
|
guard let toOptions = optionsResolver() else {
|
||||||
|
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
||||||
|
+ "panel options not captured")
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
var raw: UnsafeMutablePointer<CChar>?
|
||||||
|
guard toOptions(settings, &raw) == 0, let raw else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
defer { free(raw) }
|
||||||
|
let unfiltered = String(cString: raw)
|
||||||
|
let filtered = CupsOptionsFilter.filter(unfiltered)
|
||||||
|
return (
|
||||||
|
filtered.isEmpty ? nil : filtered,
|
||||||
|
CupsParsers.extractMediaType(fromOptionsString: unfiltered)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - dlsym
|
||||||
|
|
||||||
|
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
||||||
|
guard let symbol = dlsym(Self.rtldDefault, name) else { return nil }
|
||||||
|
return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dlsymOptions() -> PrintSettingsToOptionsFunction? {
|
||||||
|
guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions")
|
||||||
|
else { return nil }
|
||||||
|
return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RTLD_DEFAULT` — `UnsafeMutableRawPointer(bitPattern: -2)`.
|
||||||
|
private static var rtldDefault: UnsafeMutableRawPointer? {
|
||||||
|
UnsafeMutableRawPointer(bitPattern: -2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Errors raised while preparing the bound print panel.
|
||||||
|
enum PrintPanelError: LocalizedError {
|
||||||
|
case sessionBindingFailed(OSStatus)
|
||||||
|
case noPrinterFound(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .sessionBindingFailed(let status):
|
||||||
|
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||||
|
case .noPrinterFound(let name):
|
||||||
|
return "No printer found for '\(name)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
|
/// queue (issue 13, docs/11).
|
||||||
|
///
|
||||||
|
/// This is a **settings-capture** dialog — the default button is
|
||||||
|
/// "Use Settings", never "Print". It is never System Settings, the
|
||||||
|
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
|
||||||
|
/// and is not an error.
|
||||||
|
///
|
||||||
|
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)` →
|
||||||
|
/// `PMSessionSetCurrentPMPrinter` → session default settings/page
|
||||||
|
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
|
||||||
|
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
|
||||||
|
/// label) → `printInfo.printer`.
|
||||||
|
@MainActor
|
||||||
|
struct PrintPanelService {
|
||||||
|
|
||||||
|
/// The suppression engine — injectable for tests.
|
||||||
|
var suppressor = ColorSyncSuppressor()
|
||||||
|
|
||||||
|
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
|
||||||
|
/// the modal panel. Returns `nil` when the user cancels.
|
||||||
|
func showProperties(
|
||||||
|
queue: String,
|
||||||
|
displayName: String?,
|
||||||
|
cupsService: CupsService
|
||||||
|
) async throws -> PrintPropertiesResult? {
|
||||||
|
#if DEBUG
|
||||||
|
if UITestHooks.printPanelStubbed {
|
||||||
|
return UITestHooks.printPanelResult(forQueue: queue)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||||
|
let fetched = try? await cupsService.displayName(for: queue)
|
||||||
|
let display = displayName ?? fetched
|
||||||
|
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
||||||
|
// the driver colour-bypass before the panel opens.
|
||||||
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
|
?? []
|
||||||
|
return try runNativePanel(
|
||||||
|
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panel
|
||||||
|
|
||||||
|
private func runNativePanel(
|
||||||
|
queue: String,
|
||||||
|
displayName: String?,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) throws -> PrintPropertiesResult? {
|
||||||
|
let printInfo = NSPrintInfo()
|
||||||
|
var pmPrinter: PMPrinter?
|
||||||
|
var boundViaPM = false
|
||||||
|
|
||||||
|
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||||
|
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
||||||
|
pmPrinter = printer
|
||||||
|
let session = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||||
|
let settings = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
let pageFormat = unsafeBitCast(
|
||||||
|
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||||
|
|
||||||
|
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||||
|
if status != 0 {
|
||||||
|
PMRelease(Self.pmObject(printer))
|
||||||
|
throw PrintPanelError.sessionBindingFailed(status)
|
||||||
|
}
|
||||||
|
// Warn-only: defaults keep the panel consistent with the
|
||||||
|
// queue but are not fatal when they fail.
|
||||||
|
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||||
|
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||||
|
boundViaPM = true
|
||||||
|
} else {
|
||||||
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
|
guard let displayName,
|
||||||
|
let nsPrinter = NSPrinter(name: displayName)
|
||||||
|
else {
|
||||||
|
throw PrintPanelError.noPrinterFound(
|
||||||
|
displayName ?? queue)
|
||||||
|
}
|
||||||
|
printInfo.printer = nsPrinter
|
||||||
|
printInfo.setUpPrintOperationDefaultValues()
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if let printer = pmPrinter {
|
||||||
|
PMRelease(Self.pmObject(printer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||||
|
// and PMPrintSettingsSetValue need a session with a current
|
||||||
|
// printer to attach to.
|
||||||
|
var settings = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
var driverBypass: (key: String, value: String)?
|
||||||
|
if boundViaPM {
|
||||||
|
let session = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||||
|
suppressor.applySPIMode(to: session) // ②
|
||||||
|
suppressor.applyLockedKeys(to: settings) // ③
|
||||||
|
driverBypass = suppressor.applyDriverBypass( // ④
|
||||||
|
to: settings, optionKeys: optionKeys)
|
||||||
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel = NSPrintPanel()
|
||||||
|
panel.options = [
|
||||||
|
.showsCopies, .showsPageRange, .showsPaperSize,
|
||||||
|
.showsOrientation, .showsScaling, .showsPrintSelection,
|
||||||
|
.showsPageSetupAccessory, .showsPreview,
|
||||||
|
]
|
||||||
|
panel.setDefaultButtonTitle("Use Settings")
|
||||||
|
|
||||||
|
let response = panel.runModal(with: printInfo)
|
||||||
|
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ Capture the user's choices — filtered replay options plus
|
||||||
|
// the media type they picked. Re-fetch the settings handle so
|
||||||
|
// we read back what the modal wrote.
|
||||||
|
var cupsOptions: String?
|
||||||
|
var mediaType: String?
|
||||||
|
if boundViaPM {
|
||||||
|
settings = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
let captured = suppressor.captureOptions(from: settings)
|
||||||
|
cupsOptions = captured.cupsOptions
|
||||||
|
mediaType = captured.mediaType
|
||||||
|
}
|
||||||
|
return PrintPropertiesResult(
|
||||||
|
selectedPrinter: boundViaPM
|
||||||
|
? Self.currentPrinterID(
|
||||||
|
session: unsafeBitCast(
|
||||||
|
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||||
|
fallback: queue)
|
||||||
|
: nil,
|
||||||
|
options: PrintOptions(
|
||||||
|
mediaType: mediaType,
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: cupsOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PM helpers
|
||||||
|
|
||||||
|
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||||
|
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
||||||
|
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
||||||
|
unsafeBitCast(printer, to: PMObject.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||||
|
private static func currentPrinterID(
|
||||||
|
session: PMPrintSession,
|
||||||
|
fallback: String
|
||||||
|
) -> String {
|
||||||
|
var current: PMPrinter?
|
||||||
|
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||||
|
let printer = current
|
||||||
|
else { return fallback }
|
||||||
|
defer { PMRelease(pmObject(printer)) }
|
||||||
|
guard let id = PMPrinterGetID(printer)
|
||||||
|
else { return fallback }
|
||||||
|
return id.takeUnretainedValue() as String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// User-facing FWA selection for the Stage 4 form.
|
||||||
|
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||||
|
case none = "none"
|
||||||
|
case empty = ""
|
||||||
|
case D50 = "D50"
|
||||||
|
case D65 = "D65"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .empty: return "Bare (-f)"
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return "Custom .sp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ProfileWorkflowViewModel {
|
||||||
|
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Stage 4 form
|
||||||
|
|
||||||
|
var algorithm: String = "l" // l | x | X | m
|
||||||
|
var quality: String = "m" // l | m | h | u
|
||||||
|
var intent: String = "" // usually empty at Stage 4
|
||||||
|
var fwaSelection: ColprofFwaSelection = .none
|
||||||
|
var fwaCustomPath: String = ""
|
||||||
|
var illuminant: String = ""
|
||||||
|
var observer: String = ""
|
||||||
|
var inputViewingCond: String = ""
|
||||||
|
var outputViewingCond: String = ""
|
||||||
|
var profileDescription: String = ""
|
||||||
|
var copyright: String = ""
|
||||||
|
|
||||||
|
// MARK: - Run state
|
||||||
|
|
||||||
|
var isColprofRunning = false
|
||||||
|
var colprofLog: [String] = []
|
||||||
|
var colprofProgress: String?
|
||||||
|
var lastError: String?
|
||||||
|
var createdProfileURL: URL?
|
||||||
|
|
||||||
|
// MARK: - Stage 4/5 calibration (issue #24)
|
||||||
|
|
||||||
|
var applyCalibration = false
|
||||||
|
var calibrationFile: String = ""
|
||||||
|
|
||||||
|
// MARK: - Stage 5 verification (issue #25)
|
||||||
|
|
||||||
|
var profcheckReport: ProfcheckReport?
|
||||||
|
var profcheckWarning: String?
|
||||||
|
var isProfcheckRunning = false
|
||||||
|
|
||||||
|
// MARK: - History / drift (issue #26)
|
||||||
|
|
||||||
|
var verificationHistory: [VerificationRecord] = []
|
||||||
|
var driftPrinterFilter: String? = nil
|
||||||
|
var driftAlert: String?
|
||||||
|
var isHistoryStoreError: String?
|
||||||
|
|
||||||
|
// MARK: - Install (issue #27)
|
||||||
|
|
||||||
|
var installResult: InstallProfileResult?
|
||||||
|
var showingInstallCollision = false
|
||||||
|
var installCollisionMessage: String = ""
|
||||||
|
var pendingInstallOptions: InstallProfileOptions?
|
||||||
|
|
||||||
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
self.wizard = wizard
|
||||||
|
self.environment = environment
|
||||||
|
restoreCreatedProfileURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores `createdProfileURL` from the wizard artefacts or by probing
|
||||||
|
/// the working directory for an existing `.icc`/`.icm` (#52).
|
||||||
|
func restoreCreatedProfileURL() {
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
|
||||||
|
createdProfileURL = wizard.artefacts.profilePath
|
||||||
|
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var canCreateProfile: Bool {
|
||||||
|
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var canVerify: Bool {
|
||||||
|
createdProfileURL != nil && !isProfcheckRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var fwaValue: String? {
|
||||||
|
switch fwaSelection {
|
||||||
|
case .none: return nil
|
||||||
|
case .empty: return ""
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return fwaCustomPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preset application
|
||||||
|
|
||||||
|
func applyPreset(_ preset: ProfilingPreset?) {
|
||||||
|
guard let preset else { return }
|
||||||
|
algorithm = preset.colprofAlgorithm ?? "l"
|
||||||
|
quality = preset.colprofQuality ?? "m"
|
||||||
|
intent = preset.colprofIntent ?? ""
|
||||||
|
|
||||||
|
if let fwa = preset.colprofFwa {
|
||||||
|
switch fwa.lowercased() {
|
||||||
|
case "none": fwaSelection = .none
|
||||||
|
case "": fwaSelection = .empty
|
||||||
|
case "d50": fwaSelection = .D50
|
||||||
|
case "d65": fwaSelection = .D65
|
||||||
|
default:
|
||||||
|
fwaSelection = .custom
|
||||||
|
fwaCustomPath = fwa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
illuminant = preset.colprofIlluminant ?? ""
|
||||||
|
observer = preset.colprofObserver ?? ""
|
||||||
|
inputViewingCond = preset.colprofInputViewingCond ?? ""
|
||||||
|
outputViewingCond = preset.colprofOutputViewingCond ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 4 form values for saving into a custom preset.
|
||||||
|
func presetSnapshot() -> (
|
||||||
|
algorithm: String,
|
||||||
|
quality: String,
|
||||||
|
intent: String?,
|
||||||
|
fwa: String?,
|
||||||
|
illuminant: String?,
|
||||||
|
observer: String?,
|
||||||
|
inputViewingCond: String?,
|
||||||
|
outputViewingCond: String?
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
algorithm: algorithm,
|
||||||
|
quality: quality,
|
||||||
|
intent: intent.isEmpty ? nil : intent,
|
||||||
|
fwa: fwaValue,
|
||||||
|
illuminant: illuminant.isEmpty ? nil : illuminant,
|
||||||
|
observer: observer.isEmpty ? nil : observer,
|
||||||
|
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
|
||||||
|
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 4: build profile
|
||||||
|
|
||||||
|
func buildColprofConfig() -> ColprofConfig {
|
||||||
|
let description = profileDescription.isEmpty ? wizard.basename : profileDescription
|
||||||
|
return ColprofConfig(
|
||||||
|
algorithm: algorithm,
|
||||||
|
quality: quality,
|
||||||
|
intent: intent.isEmpty ? nil : intent,
|
||||||
|
fwa: fwaValue,
|
||||||
|
illuminant: illuminant.isEmpty ? nil : illuminant,
|
||||||
|
observer: observer.isEmpty ? nil : observer,
|
||||||
|
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
|
||||||
|
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond,
|
||||||
|
description: description,
|
||||||
|
copyright: copyright.isEmpty ? nil : copyright,
|
||||||
|
basename: wizard.basename,
|
||||||
|
workingDirectory: wizard.effectiveWorkingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProfile() {
|
||||||
|
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
let config = buildColprofConfig()
|
||||||
|
|
||||||
|
isColprofRunning = true
|
||||||
|
colprofLog = []
|
||||||
|
colprofProgress = nil
|
||||||
|
lastError = nil
|
||||||
|
createdProfileURL = nil
|
||||||
|
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isColprofRunning = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = try await runner.runColprof(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.colprofLog.append(contentsOf: batch)
|
||||||
|
if let last = batch.last {
|
||||||
|
let progress = ColprofProgressClassifier.classify(line: last)
|
||||||
|
self.updateProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalProfileURL = url
|
||||||
|
|
||||||
|
if self.applyCalibration, !self.calibrationFile.isEmpty {
|
||||||
|
let applyConfig = ApplycalConfig(
|
||||||
|
calibrationPath: self.calibrationFile,
|
||||||
|
inputProfileURL: url
|
||||||
|
)
|
||||||
|
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
|
||||||
|
finalProfileURL = try await runner.runApplycal(config: applyConfig)
|
||||||
|
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
do {
|
||||||
|
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||||
|
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.colprofLog.append("Gamut mesh extracted.")
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||||
|
kind: .info
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.createdProfileURL = finalProfileURL
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
||||||
|
self.wizard.go(to: .verifyInstall)
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Profile creation failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress(_ progress: ColprofProgress) {
|
||||||
|
switch progress {
|
||||||
|
case .gamutMapping:
|
||||||
|
colprofProgress = "Gamut mapping calculation…"
|
||||||
|
case .fittingClut:
|
||||||
|
colprofProgress = "Fitting cLUT grid points…"
|
||||||
|
case .writingIcc:
|
||||||
|
colprofProgress = "Writing ICC profile…"
|
||||||
|
case .unknown:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 5: verify profile
|
||||||
|
|
||||||
|
var knownPrinters: [String] {
|
||||||
|
var names = Set<String>()
|
||||||
|
for record in verificationHistory {
|
||||||
|
if record.printerName.isEmpty {
|
||||||
|
names.insert("Unknown")
|
||||||
|
} else {
|
||||||
|
names.insert(record.printerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array(names).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadHistory() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
self.verificationHistory = try await self.environment.historyStore.load()
|
||||||
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
|
} catch {
|
||||||
|
self.isHistoryStoreError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Could not load verification history: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredHistory: [VerificationRecord] {
|
||||||
|
guard let filter = driftPrinterFilter, !filter.isEmpty else {
|
||||||
|
return verificationHistory
|
||||||
|
}
|
||||||
|
return verificationHistory.filter { $0.printerName == filter }
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyProfile() {
|
||||||
|
guard canVerify,
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory,
|
||||||
|
let profileURL = createdProfileURL else { return }
|
||||||
|
|
||||||
|
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
||||||
|
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
||||||
|
|
||||||
|
isProfcheckRunning = true
|
||||||
|
profcheckReport = nil
|
||||||
|
profcheckWarning = nil
|
||||||
|
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isProfcheckRunning = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.profcheckReport = report
|
||||||
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
|
let updated = try await self.environment.historyStore.append(record)
|
||||||
|
self.verificationHistory = updated
|
||||||
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
|
}
|
||||||
|
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||||
|
self.profcheckWarning = "profcheck output could not be parsed."
|
||||||
|
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
|
||||||
|
} catch {
|
||||||
|
self.profcheckWarning = error.localizedDescription
|
||||||
|
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Verification failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? {
|
||||||
|
guard let avg = report.avgDE,
|
||||||
|
let max = report.maxDE,
|
||||||
|
let rms = report.rmsDE,
|
||||||
|
let status = report.status else { return nil }
|
||||||
|
|
||||||
|
let timestamp = Date()
|
||||||
|
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
|
||||||
|
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
|
||||||
|
return VerificationRecord(
|
||||||
|
id: id,
|
||||||
|
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
|
||||||
|
printerName: printerName,
|
||||||
|
avgDE: avg,
|
||||||
|
maxDE: max,
|
||||||
|
rmsDE: rms,
|
||||||
|
patchCount: report.patchCount ?? 0,
|
||||||
|
status: status,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextSeq() -> Int {
|
||||||
|
Int.random(in: 0..<1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.environment.historyStore.clear()
|
||||||
|
self.verificationHistory = []
|
||||||
|
self.driftAlert = nil
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Could not clear history: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Profile install
|
||||||
|
|
||||||
|
func beginInstallProfile() {
|
||||||
|
guard let sourceURL = createdProfileURL,
|
||||||
|
let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
let preferSystem = settings.defaultInstallLocation == .system
|
||||||
|
let options = InstallProfileOptions(
|
||||||
|
forceOverwrite: !settings.askBeforeOverwriteProfile,
|
||||||
|
preferSystem: preferSystem,
|
||||||
|
collisionPolicy: .overwrite,
|
||||||
|
openColorPanel: settings.openColorPanelAfterInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
|
||||||
|
let destURL = try ProfileInstaller.resolveDestinationURL(for: config)
|
||||||
|
let collision = FileManager.default.fileExists(atPath: destURL.path)
|
||||||
|
|
||||||
|
if collision && settings.askBeforeOverwriteProfile {
|
||||||
|
pendingInstallOptions = options
|
||||||
|
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
|
||||||
|
showingInstallCollision = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runInstall(sourceURL: sourceURL, options: options)
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Install failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
|
||||||
|
showingInstallCollision = false
|
||||||
|
guard let sourceURL = createdProfileURL,
|
||||||
|
var options = pendingInstallOptions else { return }
|
||||||
|
|
||||||
|
if policy == .cancel {
|
||||||
|
installResult = InstallProfileResult(
|
||||||
|
destPath: "",
|
||||||
|
registered: false,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: false,
|
||||||
|
openedPanel: false,
|
||||||
|
message: "Install cancelled."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.collisionPolicy = policy
|
||||||
|
if policy == .overwrite {
|
||||||
|
options.forceOverwrite = true
|
||||||
|
}
|
||||||
|
runInstall(sourceURL: sourceURL, options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
|
||||||
|
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
|
||||||
|
Task(priority: .userInitiated) { [weak self] in
|
||||||
|
do {
|
||||||
|
let result = try ProfileInstaller.install(config: config)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.installResult = result
|
||||||
|
self?.wizard.showNotice(result.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.wizard.showNotice(
|
||||||
|
"Install failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportHistory() {
|
||||||
|
guard let url = fileDialogs.selectCsvSavePath() else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let csv = await self.environment.historyStore.exportCSV()
|
||||||
|
do {
|
||||||
|
try csv.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
self.wizard.showNotice("History exported: \(url.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Export failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File pickers
|
||||||
|
|
||||||
|
func browseForSpectrumFile() {
|
||||||
|
let start = wizard.effectiveWorkingDirectory
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? nil
|
||||||
|
: fileDialogs.selectSpectrumFile(startingAt: start)
|
||||||
|
if let url {
|
||||||
|
fwaSelection = .custom
|
||||||
|
fwaCustomPath = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseForCalibrationFile() {
|
||||||
|
let start = wizard.effectiveWorkingDirectory
|
||||||
|
let url = fileDialogs.selectCalFile(startingAt: start)
|
||||||
|
if let url {
|
||||||
|
calibrationFile = url.path
|
||||||
|
applyCalibration = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||||
/// banner pinned to the top (docs/21 §Shell).
|
/// banner pinned to the top (docs/21 §Shell).
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var model: WizardViewModel
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
|
@State private var showingAllHelp = false
|
||||||
|
|
||||||
|
private var model: WizardViewModel { workflow.wizard }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
SidebarView(
|
SidebarView(
|
||||||
model: model,
|
workflow: workflow,
|
||||||
onOpenSettings: { showingSettings = true },
|
onOpenSettings: { showingSettings = true },
|
||||||
onOpenAbout: { showingAbout = true }
|
onOpenAbout: { showingAbout = true },
|
||||||
|
showingAllHelp: $showingAllHelp
|
||||||
)
|
)
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -23,26 +29,47 @@ struct RootView: View {
|
|||||||
if let notice = model.notice {
|
if let notice = model.notice {
|
||||||
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
||||||
}
|
}
|
||||||
StagePlaceholderView(stage: model.stage)
|
stageContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1100, minHeight: 700)
|
.frame(minWidth: 1100, minHeight: 700)
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
|
// #151: re-probe artefacts when the window regains focus —
|
||||||
|
// files deleted in Finder must re-lock stages.
|
||||||
|
.onReceive(
|
||||||
|
NotificationCenter.default.publisher(
|
||||||
|
for: NSWindow.didBecomeKeyNotification
|
||||||
|
)
|
||||||
|
) { _ in model.windowDidBecomeKey() }
|
||||||
.sheet(isPresented: $showingSettings) {
|
.sheet(isPresented: $showingSettings) {
|
||||||
// Full settings dialog lands in issue #5.
|
SettingsView()
|
||||||
VStack(spacing: 12) {
|
|
||||||
Text("Settings").font(.headline)
|
|
||||||
Text("Implemented in issue #5.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button("Close") { showingSettings = false }
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.frame(width: 420)
|
|
||||||
}
|
}
|
||||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
.sheet(isPresented: $workflow.showingSavePreset) {
|
||||||
Button("OK") {}
|
SavePresetDialog(workflow: workflow)
|
||||||
} message: {
|
}
|
||||||
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
|
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||||
|
ManagePresetsDialog(workflow: workflow)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAbout) {
|
||||||
|
AboutView { showingAbout = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var stageContent: some View {
|
||||||
|
switch model.stage {
|
||||||
|
case .generate:
|
||||||
|
Stage1View(workflow: workflow)
|
||||||
|
case .layOutPrint:
|
||||||
|
Stage2View(workflow: workflow)
|
||||||
|
case .measure:
|
||||||
|
Stage3View(model: workflow.measurement)
|
||||||
|
case .buildProfile:
|
||||||
|
Stage4View(model: workflow.profile)
|
||||||
|
case .verifyInstall:
|
||||||
|
Stage5View(model: workflow.profile)
|
||||||
|
default:
|
||||||
|
StagePlaceholderView(stage: model.stage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
|
struct SettingsView: View {
|
||||||
|
@State var model = SettingsViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
|
("i1", "X-Rite i1Pro / i1Pro 2"),
|
||||||
|
("p3", "X-Rite i1Pro 3 / 3 Plus"),
|
||||||
|
("CM", "ColorMunki"),
|
||||||
|
("SS", "Specbos / Spectraval"),
|
||||||
|
("20", "Gretag i1Display 2"),
|
||||||
|
("22", "X-Rite i1Display Pro / ColorMunki Display"),
|
||||||
|
("41", "Datacolor Spyder 4/5"),
|
||||||
|
("51", "Spyder X"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Form {
|
||||||
|
Section("Argyll") {
|
||||||
|
HStack {
|
||||||
|
TextField(
|
||||||
|
"Bundled sidecars",
|
||||||
|
text: Binding(
|
||||||
|
get: { model.settings.argyllBinaryDir ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.argyllBinaryDir =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Button("Browse…") {
|
||||||
|
if let dir = FileDialogService.shared.selectDirectory() {
|
||||||
|
model.settings.argyllBinaryDir = dir.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Leave empty to use the bundled Argyll tools.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
"Default instrument",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.defaultInstrument ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.defaultInstrument =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("None").tag("")
|
||||||
|
ForEach(Self.instruments, id: \.code) {
|
||||||
|
Text($0.label).tag($0.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
"Enable i1Pro 2 LEDs",
|
||||||
|
isOn: $model.settings.enableI1Pro2Leds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Verification") {
|
||||||
|
HStack {
|
||||||
|
Text("Good ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"2.0",
|
||||||
|
value: $model.settings.deltaEGoodMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Warning ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"5.0",
|
||||||
|
value: $model.settings.deltaEWarningMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
ForEach(model.validationErrors, id: \.self) { error in
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Calibration") {
|
||||||
|
HStack {
|
||||||
|
Text("Stale after")
|
||||||
|
TextField(
|
||||||
|
"30",
|
||||||
|
value: $model.settings.calibrationStaleDays,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Profile install") {
|
||||||
|
Picker(
|
||||||
|
"Install location",
|
||||||
|
selection: $model.settings.defaultInstallLocation
|
||||||
|
) {
|
||||||
|
Text("User library").tag(InstallLocation.user)
|
||||||
|
Text("System library").tag(InstallLocation.system)
|
||||||
|
}
|
||||||
|
Toggle(
|
||||||
|
"Ask before overwriting a profile",
|
||||||
|
isOn: $model.settings.askBeforeOverwriteProfile
|
||||||
|
)
|
||||||
|
Toggle(
|
||||||
|
"Open ColorSync after install",
|
||||||
|
isOn: $model.settings.openColorPanelAfterInstall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Logging") {
|
||||||
|
Picker(
|
||||||
|
"Log level",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.logLevel },
|
||||||
|
set: { model.settings.logLevel = $0 }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Default").tag(LogLevel?.none)
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) {
|
||||||
|
Text($0.rawValue.capitalized).tag(LogLevel?.some($0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Open log folder") { model.openLogFolder() }
|
||||||
|
Button("Copy path") { model.copyLogPath() }
|
||||||
|
Button("Copy excerpt") { model.copyLogExcerpt() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if model.savedFlash {
|
||||||
|
Text("Saved")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button("Save") {
|
||||||
|
if model.save() { dismiss() }
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.frame(width: 560, height: 620)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Backs the Settings sheet (issue #5). Load → edit → save with
|
||||||
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel {
|
||||||
|
|
||||||
|
var settings: AppSettings
|
||||||
|
var validationErrors: [String] = []
|
||||||
|
var savedFlash = false
|
||||||
|
|
||||||
|
private let store: SettingsStore
|
||||||
|
private let sink: LogSink
|
||||||
|
|
||||||
|
init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) {
|
||||||
|
self.store = store
|
||||||
|
self.sink = sink
|
||||||
|
self.settings = store.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists after validation. Returns false (and shows inline
|
||||||
|
/// errors) when the form is invalid.
|
||||||
|
@discardableResult
|
||||||
|
func save() -> Bool {
|
||||||
|
validationErrors = settings.validate()
|
||||||
|
guard validationErrors.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
try store.save(settings)
|
||||||
|
sink.applySettings(settings)
|
||||||
|
savedFlash = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
savedFlash = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
validationErrors = ["Could not save settings: \(error.localizedDescription)"]
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log helpers
|
||||||
|
|
||||||
|
var logFileURL: URL { AppPaths.logFile }
|
||||||
|
|
||||||
|
func openLogFolder() {
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: AppPaths.logDir, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
NSWorkspace.shared.selectFile(
|
||||||
|
AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogPath() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogExcerpt() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import ICCeryCore
|
|||||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var model: WizardViewModel
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
var onOpenSettings: () -> Void
|
var onOpenSettings: () -> Void
|
||||||
var onOpenAbout: () -> Void
|
var onOpenAbout: () -> Void
|
||||||
|
@Binding var showingAllHelp: Bool
|
||||||
|
|
||||||
|
private var model: WizardViewModel { workflow.wizard }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -20,27 +23,57 @@ struct SidebarView: View {
|
|||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Settings")
|
.helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
|
||||||
|
.accessibilityIdentifier("openSettingsBtn")
|
||||||
Button(action: onOpenAbout) {
|
Button(action: onOpenAbout) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("About ICCery")
|
.helpOverlay("Open the About dialog.", showing: $showingAllHelp)
|
||||||
|
.accessibilityIdentifier("openAboutBtn")
|
||||||
|
Button(action: { showingAllHelp.toggle() }) {
|
||||||
|
Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Toggle help overlays")
|
||||||
|
.accessibilityIdentifier("btnToggleAllHelp")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
// Preset select (`#presetSelect`). Disabled until the preset
|
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||||
// engine lands in issue #11.
|
// applies the preset immediately; names render via Text only.
|
||||||
Picker("Preset", selection: .constant("none")) {
|
Picker("Preset", selection: Binding(
|
||||||
|
get: { workflow.selectedPresetID },
|
||||||
|
set: { id in
|
||||||
|
if id == "none" {
|
||||||
|
workflow.selectedPresetID = "none"
|
||||||
|
} else if let preset = workflow.presets.first(where: { $0.id == id }) {
|
||||||
|
workflow.applyPreset(preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
Text("No preset").tag("none")
|
Text("No preset").tag("none")
|
||||||
|
ForEach(workflow.presets) { preset in
|
||||||
|
Text(preset.name).tag(preset.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
.disabled(true)
|
.accessibilityIdentifier("presetSelect")
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Save") { workflow.showingSavePreset = true }
|
||||||
|
.accessibilityIdentifier("btnSavePresetModal")
|
||||||
|
Button("Manage") { workflow.showingManagePresets = true }
|
||||||
|
.accessibilityIdentifier("btnOpenPresetsDialog")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
|
||||||
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
||||||
Button(action: { model.enterCalibration() }) {
|
Button(action: { model.enterCalibration() }) {
|
||||||
@@ -60,8 +93,8 @@ struct SidebarView: View {
|
|||||||
StepperRow(
|
StepperRow(
|
||||||
stage: stage,
|
stage: stage,
|
||||||
isActive: model.stage == stage,
|
isActive: model.stage == stage,
|
||||||
// Only Stage 1 until artefact gating lands in #4.
|
// Artefact gating (issue #4) — disk is truth.
|
||||||
isEnabled: stage == .generate
|
isEnabled: model.isUnlocked(stage)
|
||||||
) {
|
) {
|
||||||
model.go(to: stage)
|
model.go(to: stage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 1 — `#stage-1` Generate Target (`targen` → `.ti1`, issue #7,
|
||||||
|
/// docs/08). All documented element ids are wired as accessibility
|
||||||
|
/// identifiers so the UI-test contract stays stable.
|
||||||
|
struct Stage1View: View {
|
||||||
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
colourSpaceSection
|
||||||
|
patchSection
|
||||||
|
targetSection
|
||||||
|
advancedSection
|
||||||
|
actionRow
|
||||||
|
logSection
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityIdentifier("stage-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Colour space (name="colourSpace")
|
||||||
|
|
||||||
|
private var colourSpaceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Colour space").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
Picker("Colour space", selection: $workflow.colourSpace) {
|
||||||
|
Text("RGB (print drivers)").tag(ColourSpace.rgb)
|
||||||
|
Text("CMYK (RIP output)").tag(ColourSpace.cmyk)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.accessibilityIdentifier("colourSpace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Patch count + white/black
|
||||||
|
|
||||||
|
private var patchSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Patches").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Picker("Patch count", selection: $workflow.patchPreset) {
|
||||||
|
ForEach(PatchCountPreset.allCases, id: \.self) {
|
||||||
|
Text($0.title).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("patchCountPreset")
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
|
||||||
|
if workflow.patchPreset == .custom {
|
||||||
|
TextField("Patches", value: $workflow.customPatchCount, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 90)
|
||||||
|
.accessibilityIdentifier("patchCountCustom")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Stepper(value: $workflow.whitePatches, in: 0...50) {
|
||||||
|
Text("White patches: \(workflow.whitePatches)")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("whitePatches")
|
||||||
|
Stepper(value: $workflow.blackPatches, in: 0...50) {
|
||||||
|
Text("Black patches: \(workflow.blackPatches)")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("blackPatches")
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Target file / working directory
|
||||||
|
|
||||||
|
private var targetSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Target file").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Basename (no extension)", text: $workflow.targetBasename)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetBasename")
|
||||||
|
Button("Browse…") { workflow.browseForTargetFile() }
|
||||||
|
.accessibilityIdentifier("btnBrowse")
|
||||||
|
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
|
||||||
|
Button("Open Existing…") { workflow.openExistingTarget() }
|
||||||
|
.accessibilityIdentifier("btnOpenExisting")
|
||||||
|
Button("Import Dataset…") { workflow.importMeasurementDataset() }
|
||||||
|
.accessibilityIdentifier("btn-import-dataset")
|
||||||
|
}
|
||||||
|
Text(workflow.targetDirectory?.path ?? "No working directory selected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.accessibilityIdentifier("selectedPathDisplay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Advanced (#targenAdvancedDetails)
|
||||||
|
|
||||||
|
/// UI tests pre-expand the group — XCUI cannot reliably toggle a
|
||||||
|
/// macOS `DisclosureTriangle` (its click lands on the label).
|
||||||
|
@State private var advancedExpanded = UITestHooks.isEnabled
|
||||||
|
|
||||||
|
private var advancedSection: some View {
|
||||||
|
DisclosureGroup("Advanced", isExpanded: $advancedExpanded) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
optionalInt("Grey steps (-g)",
|
||||||
|
enabled: $workflow.greyStepsEnabled,
|
||||||
|
value: $workflow.greySteps)
|
||||||
|
.accessibilityIdentifier("targenGreySteps")
|
||||||
|
optionalInt("Single-channel steps (-s)",
|
||||||
|
enabled: $workflow.singleChannelEnabled,
|
||||||
|
value: $workflow.singleChannelSteps)
|
||||||
|
.accessibilityIdentifier("targenSingleChannelSteps")
|
||||||
|
optionalInt("Neutral steps (-n)",
|
||||||
|
enabled: $workflow.neutralStepsEnabled,
|
||||||
|
value: $workflow.neutralSteps)
|
||||||
|
.accessibilityIdentifier("targenNeutralSteps")
|
||||||
|
optionalDouble("Neutral concentration (-N)",
|
||||||
|
enabled: $workflow.neutralConcEnabled,
|
||||||
|
value: $workflow.neutralConcentration,
|
||||||
|
range: 0.0...1.0)
|
||||||
|
.accessibilityIdentifier("targenNeutralConcentration")
|
||||||
|
optionalDouble("OFPS adaptation (-A)",
|
||||||
|
enabled: $workflow.adaptationEnabled,
|
||||||
|
value: $workflow.adaptation,
|
||||||
|
range: 0.0...1.0)
|
||||||
|
.accessibilityIdentifier("targenAdaptation")
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
TextField("Preconditioning profile",
|
||||||
|
text: Binding(
|
||||||
|
get: { workflow.preconditioningProfile ?? "" },
|
||||||
|
set: {
|
||||||
|
workflow.preconditioningProfile =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targenPrecondProfile")
|
||||||
|
Button("…") { workflow.browseForPreconditioningProfile() }
|
||||||
|
.accessibilityIdentifier("btnBrowsePrecondProfile")
|
||||||
|
}
|
||||||
|
Toggle("OFPS high quality (-G)", isOn: $workflow.highQuality)
|
||||||
|
.accessibilityIdentifier("targenHighQuality")
|
||||||
|
Picker("Full-spread algorithm", selection: $workflow.algorithm) {
|
||||||
|
ForEach(FullSpreadAlgorithm.allCases, id: \.self) {
|
||||||
|
Text($0.displayName).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("targenAlgorithm")
|
||||||
|
if workflow.colourSpace == .cmyk {
|
||||||
|
optionalInt("Total ink limit (-l)",
|
||||||
|
enabled: $workflow.inkLimitEnabled,
|
||||||
|
value: $workflow.totalInkLimit)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("targenInkLimitGroup")
|
||||||
|
}
|
||||||
|
optionalDouble("Dark emphasis (-V)",
|
||||||
|
enabled: $workflow.darkEmphasisEnabled,
|
||||||
|
value: $workflow.darkEmphasis,
|
||||||
|
range: 0.0...3.0)
|
||||||
|
.accessibilityIdentifier("targenDarkEmphasis")
|
||||||
|
optionalDouble("Device power (-p)",
|
||||||
|
enabled: $workflow.devicePowerEnabled,
|
||||||
|
value: $workflow.devicePower,
|
||||||
|
range: 0.0...3.0)
|
||||||
|
.accessibilityIdentifier("targenDevicePower")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("targenAdvancedDetails")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionalInt(
|
||||||
|
_ title: String,
|
||||||
|
enabled: Binding<Bool>,
|
||||||
|
value: Binding<Int>
|
||||||
|
) -> some View {
|
||||||
|
HStack {
|
||||||
|
Toggle(title, isOn: enabled)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
if enabled.wrappedValue {
|
||||||
|
TextField("", value: value, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionalDouble(
|
||||||
|
_ title: String,
|
||||||
|
enabled: Binding<Bool>,
|
||||||
|
value: Binding<Double>,
|
||||||
|
range: ClosedRange<Double>
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Toggle(title, isOn: enabled)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
if enabled.wrappedValue {
|
||||||
|
HStack {
|
||||||
|
Slider(value: value, in: range)
|
||||||
|
Text(value.wrappedValue, format: .number.precision(.fractionLength(2)))
|
||||||
|
.frame(width: 44)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions + log
|
||||||
|
|
||||||
|
private var actionRow: some View {
|
||||||
|
HStack {
|
||||||
|
Button(action: workflow.generateTarget) {
|
||||||
|
Label(workflow.targenRunning ? "Generating…" : "Generate Target",
|
||||||
|
systemImage: "square.grid.3x3")
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(!workflow.canGenerate || workflow.targenRunning)
|
||||||
|
.accessibilityIdentifier("btnGenerate")
|
||||||
|
if workflow.targenRunning {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var logSection: some View {
|
||||||
|
DisclosureGroup("Process log") {
|
||||||
|
ScrollView {
|
||||||
|
Text(workflow.targenLog.joined(separator: "\n"))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 120, maxHeight: 200)
|
||||||
|
.accessibilityIdentifier("targenLog")
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("targenLogContainer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 2 — `#stage-2` Lay Out & Print (`printtarg` → `.ti2` + TIFFs,
|
||||||
|
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||||
|
/// real spooling lands in M3.
|
||||||
|
struct Stage2View: View {
|
||||||
|
@Bindable var workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
cmWarning
|
||||||
|
formSection
|
||||||
|
labelSection
|
||||||
|
actionRow
|
||||||
|
logSection
|
||||||
|
gallerySection
|
||||||
|
printPanel
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.background(Theme.background)
|
||||||
|
.accessibilityIdentifier("stage-2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Colour-management warning (#cmWarningBanner)
|
||||||
|
|
||||||
|
private var cmWarning: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Set your printer driver to “No Colour Adjustment” " +
|
||||||
|
"(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " +
|
||||||
|
"Any driver colour management corrupts the target.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||||
|
.stroke(Color.orange.opacity(0.4))
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier("cmWarningBanner")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout form
|
||||||
|
|
||||||
|
private var formSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Picker("Instrument", selection: $workflow.instrument) {
|
||||||
|
ForEach(PrintInstrument.allCases, id: \.self) {
|
||||||
|
Text($0.displayName).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("instrumentSelect")
|
||||||
|
Picker("Page size", selection: $workflow.pageSize) {
|
||||||
|
ForEach(PageSize.allCases, id: \.self) {
|
||||||
|
Text($0.rawValue).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("pageSizeSelect")
|
||||||
|
}
|
||||||
|
if workflow.pageSize == .custom {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Custom size (mm):")
|
||||||
|
TextField("W", value: $workflow.customPageW, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder).frame(width: 70)
|
||||||
|
.accessibilityIdentifier("customPageW")
|
||||||
|
Text("×")
|
||||||
|
TextField("H", value: $workflow.customPageH, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder).frame(width: 70)
|
||||||
|
.accessibilityIdentifier("customPageH")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("customPageSizeRow")
|
||||||
|
}
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Picker("Bit depth", selection: $workflow.bitDepth) {
|
||||||
|
Text("8-bit TIFF").tag(TiffBitDepth.eight)
|
||||||
|
Text("16-bit TIFF").tag(TiffBitDepth.sixteen)
|
||||||
|
}
|
||||||
|
Stepper("DPI: \(workflow.tiffDpi)",
|
||||||
|
value: $workflow.tiffDpi, in: 72...600, step: 1)
|
||||||
|
.accessibilityIdentifier("tiffDpi")
|
||||||
|
}
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Picker("Layout order", selection: $workflow.layoutOrder) {
|
||||||
|
ForEach(LayoutOrder.allCases, id: \.self) {
|
||||||
|
Text($0.displayName).tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("printtargLayoutOrder")
|
||||||
|
if workflow.layoutOrder == .customSeed {
|
||||||
|
HStack {
|
||||||
|
Text("Seed:")
|
||||||
|
TextField("", value: $workflow.customSeed, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder).frame(width: 80)
|
||||||
|
.accessibilityIdentifier("printtargCustomSeed")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("printtargCustomSeedGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview)
|
||||||
|
|
||||||
|
private var labelSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Chart label").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") {
|
||||||
|
workflow.labelIsCustom.toggle()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("btnToggleLabelEdit")
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("Printer", text: $workflow.metaPrinter)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetMetadataPrinter")
|
||||||
|
TextField("Ink set", text: $workflow.metaInkSet)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetMetadataInkSet")
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("Driver paper", text: $workflow.metaDriverPaper)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetMetadataDriverPaper")
|
||||||
|
TextField("Actual paper", text: $workflow.metaActualPaper)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetMetadataActualPaper")
|
||||||
|
}
|
||||||
|
if workflow.labelIsCustom {
|
||||||
|
TextField("Custom label", text: $workflow.customLabel)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("targetLabelPreview")
|
||||||
|
} else {
|
||||||
|
Text(workflow.automaticLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("targetLabelPreview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions + log
|
||||||
|
|
||||||
|
private var actionRow: some View {
|
||||||
|
HStack {
|
||||||
|
Button(action: workflow.createLayout) {
|
||||||
|
Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout",
|
||||||
|
systemImage: "rectangle.grid.2x2")
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnCreateLayout")
|
||||||
|
if workflow.printtargRunning {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var logSection: some View {
|
||||||
|
DisclosureGroup("Process log") {
|
||||||
|
ScrollView {
|
||||||
|
Text(workflow.printtargLog.joined(separator: "\n"))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 100, maxHeight: 180)
|
||||||
|
.accessibilityIdentifier("printtargLog")
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("printtargLogContainer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var gallerySection: some View {
|
||||||
|
if let result = workflow.printtargResult {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Target pages — \(result.manifest.pages.count) page(s), " +
|
||||||
|
"\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches")
|
||||||
|
.font(.headline).foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("galleryInfo")
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 220))],
|
||||||
|
spacing: 12
|
||||||
|
) {
|
||||||
|
ForEach(result.pages) { page in
|
||||||
|
GalleryPageView(page: page, workflow: workflow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("galleryGrid")
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("tiffGallery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
|
||||||
|
|
||||||
|
private var printPanel: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||||
|
if let notice = workflow.printNotice {
|
||||||
|
Image(systemName: workflow.printNoticeIsError
|
||||||
|
? "xmark.circle.fill" : "info.circle.fill")
|
||||||
|
.foregroundStyle(workflow.printNoticeIsError
|
||||||
|
? .red : .blue)
|
||||||
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(workflow.printNoticeIsError
|
||||||
|
? .red : .secondary)
|
||||||
|
.accessibilityIdentifier("printNotificationText")
|
||||||
|
.accessibilityValue(notice)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printer row: select + status + refresh + Preferences.
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
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.selectedPrinter) { _, _ in
|
||||||
|
workflow.selectedTray = nil
|
||||||
|
workflow.selectedMediaType = nil
|
||||||
|
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
.background(Theme.background)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
|
}
|
||||||
|
Button(action: workflow.refreshPrinters) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.help("Refresh printer list")
|
||||||
|
.accessibilityIdentifier("btnRefreshPrinters")
|
||||||
|
Button(action: workflow.openPrinterPreferences) {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
.help("Printer properties — bound NSPrintPanel")
|
||||||
|
.disabled(workflow.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnPrinterProperties")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tray / media / orientation — from queue capabilities.
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
if !workflow.printerCaps.trays.isEmpty {
|
||||||
|
Picker("Tray", selection: $workflow.selectedTray) {
|
||||||
|
ForEach(workflow.printerCaps.trays, id: \.id) {
|
||||||
|
Text($0.name).tag(Optional($0.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
.accessibilityIdentifier("printerTraySelect")
|
||||||
|
}
|
||||||
|
if !workflow.printerCaps.mediaTypes.isEmpty {
|
||||||
|
Picker("Media", selection: $workflow.selectedMediaType) {
|
||||||
|
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
||||||
|
Text($0.name).tag(Optional($0.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("mediaTypeGroup")
|
||||||
|
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||||
|
}
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Button("Portrait") { workflow.printOrientation = "portrait" }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
||||||
|
.accessibilityIdentifier("btnOrientPortrait")
|
||||||
|
Button("Landscape") { workflow.printOrientation = "landscape" }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
||||||
|
.accessibilityIdentifier("btnOrientLandscape")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: workflow.printAllPages) {
|
||||||
|
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
||||||
|
systemImage: "printer")
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(workflow.isPrinting
|
||||||
|
|| workflow.printtargResult == nil
|
||||||
|
|| workflow.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnPrintAll")
|
||||||
|
Spacer()
|
||||||
|
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||||
|
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||||
|
.disabled(workflow.printtargResult == nil
|
||||||
|
|| !workflow.wizard.isUnlocked(.measure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("rawPrintPanel")
|
||||||
|
.task(id: workflow.printtargResult?.pages.count) {
|
||||||
|
// Auto-enumerate once a manifest exists and whenever it
|
||||||
|
// changes (e.g. resume from .ti2).
|
||||||
|
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
|
workflow.refreshPrinters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One gallery cell: PNG preview + per-page Print button.
|
||||||
|
private struct GalleryPageView: View {
|
||||||
|
let page: GalleryPage
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if let png = page.previewPNG, let image = NSImage(data: png) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 240)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
Rectangle().fill(Theme.panel).frame(height: 160)
|
||||||
|
Text(page.previewError ?? "No preview")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(page.page.filename)
|
||||||
|
.font(.caption).foregroundStyle(Theme.text)
|
||||||
|
Text("\(page.page.patches) patches · " +
|
||||||
|
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
Button("Print") { workflow.printPage(page) }
|
||||||
|
.disabled(workflow.isPrinting
|
||||||
|
|| workflow.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("galleryPage-\(page.index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||||
|
struct Stage3View: View {
|
||||||
|
@Bindable var model: MeasurementWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
instrumentSection
|
||||||
|
chartreadControlsSection
|
||||||
|
xyTableSection
|
||||||
|
swatchGridSection
|
||||||
|
averagingSection
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Theme.background)
|
||||||
|
.onAppear { model.discoverPassSnapshots() }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in
|
||||||
|
model.loadSettings()
|
||||||
|
model.recomputeSwatches()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if model.resumedFromTi2 {
|
||||||
|
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("stage3LoadedTargetBanner")
|
||||||
|
}
|
||||||
|
Text(model.basename)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("stage3TargetBasename")
|
||||||
|
Text("Measure the printed chart with a spectrophotometer.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("stage3TargetMeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if model.isChartreadRunning {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.accessibilityIdentifier("readProgress")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let badge = targetBadge {
|
||||||
|
Text(badge)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Theme.border)
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("stage3TargetBadge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var targetBadge: String? {
|
||||||
|
if model.isFinished { return "All strips read" }
|
||||||
|
if model.isChartreadRunning { return "Reading" }
|
||||||
|
if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var instrumentSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Instrument")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { model.detectInstruments() }) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!model.canDetect)
|
||||||
|
.accessibilityIdentifier("btnDetectInstruments")
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.detectionError != nil {
|
||||||
|
Text(model.detectionError ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Instrument", selection: Binding(
|
||||||
|
get: { instrumentTag },
|
||||||
|
set: { newTag in
|
||||||
|
if newTag.isEmpty {
|
||||||
|
model.selectedInstrument = .auto
|
||||||
|
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
|
||||||
|
model.selectedInstrument = .device(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Auto (first available port)").tag("")
|
||||||
|
ForEach(model.instruments) { device in
|
||||||
|
Text(device.displayName).tag("\(device.port)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.accessibilityIdentifier("chartreadInstrumentSelect")
|
||||||
|
|
||||||
|
if model.selectedInstrument.isXY {
|
||||||
|
Text("XY table workflow selected.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.accessibilityIdentifier("xyTableHint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instrumentTag: String {
|
||||||
|
switch model.selectedInstrument {
|
||||||
|
case .auto:
|
||||||
|
return ""
|
||||||
|
case .device(let device):
|
||||||
|
return "\(device.port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chartread controls
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chartreadControlsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Status")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Text(model.currentPrompt ?? "Press Start to begin reading.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("chartreadPrompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.showRemoveSheetNotice {
|
||||||
|
Text("Please remove last sheet from table.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastError = model.lastError {
|
||||||
|
Text(lastError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("chartreadLastError")
|
||||||
|
.accessibilityValue(lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
controlButtons
|
||||||
|
|
||||||
|
if !model.chartreadLog.isEmpty {
|
||||||
|
DisclosureGroup("Log") {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ForEach(model.chartreadLog, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("chartreadLogContainer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var controlButtons: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if !model.isChartreadRunning {
|
||||||
|
Button("Start Read") {
|
||||||
|
model.startRead()
|
||||||
|
}
|
||||||
|
.disabled(!model.canStartRead)
|
||||||
|
.accessibilityIdentifier("btnStartRead")
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.isChartreadRunning {
|
||||||
|
switch model.chartreadState {
|
||||||
|
case .calibrating:
|
||||||
|
Button("Calibrate") { model.calibrate() }
|
||||||
|
.accessibilityIdentifier("btnCalibrate")
|
||||||
|
case .awaitingStrip:
|
||||||
|
Button("Trigger") { model.calibrate() }
|
||||||
|
.accessibilityIdentifier("btnTrigger")
|
||||||
|
Button("Done & Save") { model.doneAndSave() }
|
||||||
|
.accessibilityIdentifier("btnDoneReadEarly")
|
||||||
|
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
|
||||||
|
Button(continueTitle) { model.accept() }
|
||||||
|
.accessibilityIdentifier("btnAccept")
|
||||||
|
case .error:
|
||||||
|
Button("Retry") { model.retry() }
|
||||||
|
.accessibilityIdentifier("btnRetry")
|
||||||
|
case .allStripsRead:
|
||||||
|
Button("Done & Save") { model.doneAndSave() }
|
||||||
|
.accessibilityIdentifier("btnDoneRead")
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Cancel") { model.cancelRead() }
|
||||||
|
.accessibilityIdentifier("btnCancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var continueTitle: String {
|
||||||
|
if let key = model.requestedWarningKey {
|
||||||
|
return "Continue (send '\(key.uppercased())')"
|
||||||
|
}
|
||||||
|
return "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - XY table badges
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var xyTableSection: some View {
|
||||||
|
if model.selectedInstrument.isXY {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace")
|
||||||
|
xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign")
|
||||||
|
xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan")
|
||||||
|
xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove")
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityIdentifier("xyTablePanel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xyStep(_ label: String, active: Bool, id: String) -> some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(active ? .bold : .regular)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(active ? Theme.accent : Theme.border)
|
||||||
|
.foregroundStyle(active ? Color.white : Theme.text)
|
||||||
|
.cornerRadius(4)
|
||||||
|
.accessibilityIdentifier(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Swatch grid
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var swatchGridSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Swatches")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
statsView
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView([.horizontal, .vertical]) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(model.swatchRows) { row in
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Text(row.rowId)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 24)
|
||||||
|
ForEach(row.patches) { swatch in
|
||||||
|
SwatchPatchView(swatch: swatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 120, maxHeight: 360)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityIdentifier("swatchGrid")
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statsView: some View {
|
||||||
|
let patches = model.swatchRows.flatMap(\.patches)
|
||||||
|
let valid = patches.compactMap(\.deltaE)
|
||||||
|
let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count)
|
||||||
|
let max = valid.max() ?? 0
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let avg = avg {
|
||||||
|
Text("avg ΔE \(String(format: "%.2f", avg))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("max ΔE \(String(format: "%.2f", max))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(patches.count) patches")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("readStats")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Averaging
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var averagingSection: some View {
|
||||||
|
if !model.passSnapshots.isEmpty || model.isFinished {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Averaging")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
Spacer()
|
||||||
|
Text("\(model.passSnapshots.count) pass(es)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Theme.border)
|
||||||
|
.cornerRadius(4)
|
||||||
|
.accessibilityIdentifier("passCounterBadge")
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(model.passSnapshots, id: \.lastPathComponent) { url in
|
||||||
|
Text(url.lastPathComponent)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("passesList")
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Measure Another Sheet") {
|
||||||
|
model.measureAnotherSheet()
|
||||||
|
}
|
||||||
|
.disabled(!model.isFinished || model.isChartreadRunning)
|
||||||
|
.accessibilityIdentifier("btnMeasureAnotherSheet")
|
||||||
|
|
||||||
|
Button("Finish & Average") {
|
||||||
|
model.finishAndAverage()
|
||||||
|
}
|
||||||
|
.disabled(!model.canFinish || model.isFinishing)
|
||||||
|
.accessibilityIdentifier("btnFinishAndAverage")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notice = model.finishNotice {
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("chartreadAveragingPanel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
|
||||||
|
struct Stage4View: View {
|
||||||
|
@Bindable var model: ProfileWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
formSection
|
||||||
|
runSection
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Theme.background)
|
||||||
|
.onAppear { model.restoreCreatedProfileURL() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(model.wizard.basename)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("stage4TargetBasename")
|
||||||
|
Text("Build the ICC profile from the measured .ti3.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("stage4TargetMeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let progress = model.colprofProgress, model.isColprofRunning {
|
||||||
|
Text(progress)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Theme.border)
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("colprofProgress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Form
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var formSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Profile settings")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Picker("Algorithm", selection: $model.algorithm) {
|
||||||
|
Text("Lab cLUT").tag("l")
|
||||||
|
Text("XYZ cLUT").tag("x")
|
||||||
|
Text("Display XYZ+matrix").tag("X")
|
||||||
|
Text("Matrix").tag("m")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("colprofAlgorithm")
|
||||||
|
|
||||||
|
Picker("Quality", selection: $model.quality) {
|
||||||
|
Text("Low").tag("l")
|
||||||
|
Text("Medium").tag("m")
|
||||||
|
Text("High").tag("h")
|
||||||
|
Text("Ultra").tag("u")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("colprofQuality")
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("FWA / OBA compensation", selection: $model.fwaSelection) {
|
||||||
|
ForEach(ColprofFwaSelection.allCases, id: \.self) { selection in
|
||||||
|
Text(selection.displayName).tag(selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("colprofFwa")
|
||||||
|
|
||||||
|
if model.fwaSelection == .custom {
|
||||||
|
HStack {
|
||||||
|
TextField("Custom .sp spectrum path", text: $model.fwaCustomPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofFwaCustomPath")
|
||||||
|
Button("Browse…") { model.browseForSpectrumFile() }
|
||||||
|
.accessibilityIdentifier("btnBrowseFwaSp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
TextField("Illuminant", text: $model.illuminant)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofIlluminant")
|
||||||
|
|
||||||
|
TextField("Observer", text: $model.observer)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofObserver")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
TextField("Input viewing condition", text: $model.inputViewingCond)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofInputViewCond")
|
||||||
|
|
||||||
|
TextField("Output viewing condition", text: $model.outputViewingCond)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofOutputViewCond")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Use 'none' to skip a viewing condition.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField("Description", text: $model.profileDescription)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofDescription")
|
||||||
|
|
||||||
|
TextField("Copyright", text: $model.copyright)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofCopyright")
|
||||||
|
|
||||||
|
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
|
||||||
|
.accessibilityIdentifier("colprofApplyCalibration")
|
||||||
|
|
||||||
|
if model.applyCalibration {
|
||||||
|
HStack {
|
||||||
|
TextField("Calibration .cal file", text: $model.calibrationFile)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityIdentifier("colprofCalibrationFile")
|
||||||
|
Button("Browse…") { model.browseForCalibrationFile() }
|
||||||
|
.accessibilityIdentifier("btnBrowseCalibrationFile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Run controls
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var runSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Create Profile") {
|
||||||
|
model.createProfile()
|
||||||
|
}
|
||||||
|
.disabled(!model.canCreateProfile)
|
||||||
|
.accessibilityIdentifier("btnCreateProfile")
|
||||||
|
|
||||||
|
if model.isColprofRunning {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.accessibilityIdentifier("colprofProgressIndicator")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let lastError = model.lastError {
|
||||||
|
Text(lastError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("colprofLastError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.colprofLog.isEmpty {
|
||||||
|
DisclosureGroup("Log") {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ForEach(model.colprofLog, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("colprofLogContainer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 5 — verify the generated profile, track drift, and install.
|
||||||
|
struct Stage5View: View {
|
||||||
|
@Bindable var model: ProfileWorkflowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
verifySection
|
||||||
|
if let report = model.profcheckReport {
|
||||||
|
resultSection(report: report)
|
||||||
|
}
|
||||||
|
historySection
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Theme.background)
|
||||||
|
.onAppear {
|
||||||
|
model.restoreCreatedProfileURL()
|
||||||
|
model.loadHistory()
|
||||||
|
}
|
||||||
|
.alert("Install profile", isPresented: $model.showingInstallCollision) {
|
||||||
|
Button("Overwrite", role: .destructive) {
|
||||||
|
model.resolveInstallCollision(policy: .overwrite)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("profileOverwriteBtn")
|
||||||
|
Button("Rename") {
|
||||||
|
model.resolveInstallCollision(policy: .rename)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("profileRenameBtn")
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
model.resolveInstallCollision(policy: .cancel)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("profileCancelCollisionBtn")
|
||||||
|
} message: {
|
||||||
|
Text(model.installCollisionMessage)
|
||||||
|
.accessibilityIdentifier("profileInstallCollisionMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(model.wizard.basename)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("stage5TargetBasename")
|
||||||
|
Text("Verify the profile and compare against historical results.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("stage5TargetMeta")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let alert = model.driftAlert {
|
||||||
|
Text(alert)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.red.opacity(0.2))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("driftAlert")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let warning = model.profcheckWarning, !warning.isEmpty {
|
||||||
|
Text("⚠ \(warning)")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.red.opacity(0.2))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityIdentifier("profcheckWarningBanner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Verify
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var verifySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Verify Profile") {
|
||||||
|
model.verifyProfile()
|
||||||
|
}
|
||||||
|
.disabled(!model.canVerify)
|
||||||
|
.accessibilityIdentifier("btnVerifyProfile")
|
||||||
|
|
||||||
|
if model.isProfcheckRunning {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.accessibilityIdentifier("profcheckProgressIndicator")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let profileURL = model.createdProfileURL {
|
||||||
|
Text(profileURL.lastPathComponent)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityIdentifier("stage5ProfilePath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.colprofLog.isEmpty {
|
||||||
|
DisclosureGroup("Log") {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ForEach(model.colprofLog, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("profcheckLogContainer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result cards
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func resultSection(report: ProfcheckReport) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Verification result")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Install Profile") { model.beginInstallProfile() }
|
||||||
|
.disabled(model.createdProfileURL == nil)
|
||||||
|
.accessibilityIdentifier("btnInstallProfile")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
metricCard(title: "Avg ΔE", value: report.avgDE)
|
||||||
|
metricCard(title: "Max ΔE", value: report.maxDE)
|
||||||
|
metricCard(title: "RMS", value: report.rmsDE)
|
||||||
|
metricCard(title: "Patches", value: report.patchCount.map(Double.init))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let status = report.status {
|
||||||
|
HStack {
|
||||||
|
Text("Status")
|
||||||
|
Spacer()
|
||||||
|
Text(status.displayName)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(statusColor(status))
|
||||||
|
.accessibilityIdentifier("profcheckStatus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func metricCard(title: String, value: Double?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value.map { String(format: "%.2f", $0) } ?? "—")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var historySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("History & drift")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Picker("Printer", selection: Binding(
|
||||||
|
get: { model.driftPrinterFilter ?? "" },
|
||||||
|
set: { model.driftPrinterFilter = $0.isEmpty ? nil : $0 }
|
||||||
|
)) {
|
||||||
|
Text("All").tag("")
|
||||||
|
ForEach(model.knownPrinters, id: \.self) { printer in
|
||||||
|
Text(printer.isEmpty ? "Unknown" : printer).tag(printer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("driftPrinterFilter")
|
||||||
|
.frame(width: 200)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Export CSV") { model.exportHistory() }
|
||||||
|
.accessibilityIdentifier("btnExportHistory")
|
||||||
|
|
||||||
|
Button("Clear") { model.clearHistory() }
|
||||||
|
.accessibilityIdentifier("btnClearHistory")
|
||||||
|
}
|
||||||
|
|
||||||
|
driftChart
|
||||||
|
|
||||||
|
if !model.filteredHistory.isEmpty {
|
||||||
|
Table(of: VerificationRecord.self) {
|
||||||
|
TableColumn("Date") { record in
|
||||||
|
Text(record.timestamp.formatted(date: .numeric, time: .shortened))
|
||||||
|
}
|
||||||
|
TableColumn("Profile") { record in
|
||||||
|
Text(record.profileName)
|
||||||
|
}
|
||||||
|
TableColumn("Avg") { record in
|
||||||
|
Text(String(format: "%.2f", record.avgDE))
|
||||||
|
}
|
||||||
|
TableColumn("Max") { record in
|
||||||
|
Text(String(format: "%.2f", record.maxDE))
|
||||||
|
}
|
||||||
|
TableColumn("RMS") { record in
|
||||||
|
Text(String(format: "%.2f", record.rmsDE))
|
||||||
|
}
|
||||||
|
TableColumn("Status") { record in
|
||||||
|
Text(record.status.displayName)
|
||||||
|
.foregroundStyle(statusColor(record.status))
|
||||||
|
}
|
||||||
|
} rows: {
|
||||||
|
ForEach(model.filteredHistory) { record in
|
||||||
|
TableRow(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 120)
|
||||||
|
.accessibilityIdentifier("verificationHistoryTable")
|
||||||
|
} else {
|
||||||
|
Text("No verification records yet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Theme.panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var driftChart: some View {
|
||||||
|
let records = model.filteredHistory.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
DriftChartView(records: records)
|
||||||
|
.frame(height: 160)
|
||||||
|
.accessibilityIdentifier("driftChart")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ status: VerificationStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .excellent, .good: return .green
|
||||||
|
case .acceptable: return .yellow
|
||||||
|
case .poor: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
private extension DisplayRGB {
|
||||||
|
var color: Color {
|
||||||
|
Color(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One swatch in the live grid, with a 135° intended/measured diagonal split.
|
||||||
|
struct SwatchPatchView: View {
|
||||||
|
let swatch: Swatch
|
||||||
|
|
||||||
|
private var indicatorColor: Color {
|
||||||
|
switch swatch.classification {
|
||||||
|
case .good: return .green
|
||||||
|
case .warning: return .yellow
|
||||||
|
case .bad: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background: measured
|
||||||
|
swatch.measured.color
|
||||||
|
.clipShape(DiagonalClip(side: .bottomRight))
|
||||||
|
|
||||||
|
// Foreground: intended
|
||||||
|
swatch.intended.color
|
||||||
|
.clipShape(DiagonalClip(side: .topLeft))
|
||||||
|
|
||||||
|
// Classification dot
|
||||||
|
Circle()
|
||||||
|
.fill(indicatorColor)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
.offset(x: 6, y: 6)
|
||||||
|
}
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.stroke(Color.primary.opacity(0.2), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier("swatch-\(swatch.rowId)\(swatch.loc)")
|
||||||
|
.accessibilityLabel("\(swatch.loc) intended \(String(format: "%.0f", swatch.intended.r * 255)), measured \(String(format: "%.0f", swatch.measured.r * 255))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 135° diagonal clipping: top-left or bottom-right triangle.
|
||||||
|
///
|
||||||
|
/// A 135° line from the top-right corner to the bottom-left corner gives
|
||||||
|
/// top-left and bottom-right triangles.
|
||||||
|
private enum DiagonalSide {
|
||||||
|
case topLeft
|
||||||
|
case bottomRight
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DiagonalClip: Shape {
|
||||||
|
let side: DiagonalSide
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
switch side {
|
||||||
|
case .topLeft:
|
||||||
|
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
case .bottomRight:
|
||||||
|
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
}
|
||||||
|
path.closeSubpath()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||||
|
/// application (issues #7–#11).
|
||||||
|
///
|
||||||
|
/// `wizard` stays authoritative for persisted identity + disk gating;
|
||||||
|
/// this model owns the editable form, logs, gallery, and preset state.
|
||||||
|
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||||
|
/// coalesced log batches and completion hop back.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TargetWorkflowViewModel {
|
||||||
|
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Stage 1 form (targen)
|
||||||
|
|
||||||
|
var colourSpace: ColourSpace = .rgb {
|
||||||
|
didSet {
|
||||||
|
guard colourSpace != oldValue else { return }
|
||||||
|
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
||||||
|
blackPatches = colourSpace == .cmyk ? 0 : 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var patchPreset: PatchCountPreset = .standard800
|
||||||
|
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||||
|
var customPatchCount = 2500
|
||||||
|
var whitePatches = 4
|
||||||
|
var blackPatches = 4
|
||||||
|
|
||||||
|
// Advanced — each optional flag is enabled + value, so an untouched
|
||||||
|
// control emits nothing (#advanced fields are opt-in).
|
||||||
|
var greyStepsEnabled = false
|
||||||
|
var greySteps = 5
|
||||||
|
var singleChannelEnabled = false
|
||||||
|
var singleChannelSteps = 5
|
||||||
|
var neutralStepsEnabled = false
|
||||||
|
var neutralSteps = 3
|
||||||
|
var neutralConcEnabled = false
|
||||||
|
var neutralConcentration = 0.50
|
||||||
|
var preconditioningProfile: String?
|
||||||
|
var highQuality = false
|
||||||
|
var adaptationEnabled = false
|
||||||
|
var adaptation = 0.10
|
||||||
|
var algorithm: FullSpreadAlgorithm = .ofps
|
||||||
|
var inkLimitEnabled = false
|
||||||
|
var totalInkLimit = 320
|
||||||
|
var darkEmphasisEnabled = false
|
||||||
|
var darkEmphasis = 1.0
|
||||||
|
var devicePowerEnabled = false
|
||||||
|
var devicePower = 1.0
|
||||||
|
|
||||||
|
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||||
|
var targetBasename = ""
|
||||||
|
/// `#selectedPathDisplay` / resolved cwd.
|
||||||
|
var targetDirectory: URL?
|
||||||
|
|
||||||
|
// MARK: - Stage 2 form (printtarg)
|
||||||
|
|
||||||
|
var instrument: PrintInstrument = .i1
|
||||||
|
var pageSize: PageSize = .a4
|
||||||
|
var customPageW = 210.0
|
||||||
|
var customPageH = 297.0
|
||||||
|
var bitDepth: TiffBitDepth = .eight
|
||||||
|
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||||
|
/// regression must be visible here).
|
||||||
|
var tiffDpi = 300
|
||||||
|
var layoutOrder: LayoutOrder = .deterministic
|
||||||
|
var customSeed = 1
|
||||||
|
var labelIsCustom = false
|
||||||
|
var customLabel = ""
|
||||||
|
var metaPrinter = ""
|
||||||
|
var metaInkSet = ""
|
||||||
|
var metaDriverPaper = ""
|
||||||
|
var metaActualPaper = ""
|
||||||
|
|
||||||
|
// MARK: - Run state
|
||||||
|
|
||||||
|
var targenRunning = false
|
||||||
|
var targenLog: [String] = []
|
||||||
|
var printtargRunning = false
|
||||||
|
var printtargLog: [String] = []
|
||||||
|
var printtargResult: PrinttargResult?
|
||||||
|
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
// MARK: - Presets
|
||||||
|
|
||||||
|
var presets: [ProfilingPreset] = []
|
||||||
|
var selectedPresetID = "none"
|
||||||
|
var showingSavePreset = false
|
||||||
|
var showingManagePresets = false
|
||||||
|
var savePresetName = ""
|
||||||
|
var savePresetDesc = ""
|
||||||
|
|
||||||
|
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||||
|
/// across stage switches and can observe settings changes.
|
||||||
|
var measurement: MeasurementWorkflowViewModel
|
||||||
|
/// Stage 4/5 profile workflow, owned at the app level so it persists
|
||||||
|
/// across stage switches and can apply preset values.
|
||||||
|
var profile: ProfileWorkflowViewModel
|
||||||
|
|
||||||
|
init(environment: AppEnvironment = .live()) {
|
||||||
|
self.environment = environment
|
||||||
|
self.wizard = WizardViewModel(stateStore: environment.stateStore)
|
||||||
|
self.measurement = MeasurementWorkflowViewModel(
|
||||||
|
wizard: wizard,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
self.profile = ProfileWorkflowViewModel(
|
||||||
|
wizard: wizard,
|
||||||
|
environment: environment
|
||||||
|
)
|
||||||
|
reloadPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var effectivePatchCount: Int {
|
||||||
|
patchPreset.patchCount ?? customPatchCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var canGenerate: Bool {
|
||||||
|
PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelMetadata: TargetLabelMetadata {
|
||||||
|
TargetLabelMetadata(
|
||||||
|
printer: metaPrinter, inkSet: metaInkSet,
|
||||||
|
driverPaper: metaDriverPaper, actualPaper: metaActualPaper)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#targetLabelPreview` — live preview of the automatic label.
|
||||||
|
var automaticLabel: String {
|
||||||
|
PrinttargLabel.automatic(
|
||||||
|
basename: wizard.basename.isEmpty ? "target" : wizard.basename,
|
||||||
|
metadata: labelMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedPreset: ProfilingPreset? {
|
||||||
|
presets.first { $0.id == selectedPresetID }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 1: generate
|
||||||
|
|
||||||
|
func buildTargenConfig() -> TargenConfig {
|
||||||
|
TargenConfig(
|
||||||
|
colourSpace: colourSpace,
|
||||||
|
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 == .ofps ? nil : algorithm,
|
||||||
|
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
||||||
|
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
||||||
|
devicePower: devicePowerEnabled ? devicePower : nil,
|
||||||
|
basename: targetBasename,
|
||||||
|
workingDirectory: targetDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseForTargetFile() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.saveTargetURL
|
||||||
|
: fileDialogs.selectTargetFile()
|
||||||
|
guard let url else { return }
|
||||||
|
targetBasename = url.deletingPathExtension().lastPathComponent
|
||||||
|
targetDirectory = url.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseForWorkingDirectory() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.workDirURL
|
||||||
|
: fileDialogs.selectDirectory()
|
||||||
|
if let url { targetDirectory = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseForPreconditioningProfile() {
|
||||||
|
if let url = fileDialogs.selectProfileFile() {
|
||||||
|
preconditioningProfile = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateTarget() {
|
||||||
|
guard canGenerate, !targenRunning else { return }
|
||||||
|
let config = buildTargenConfig()
|
||||||
|
targenRunning = true
|
||||||
|
targenLog = []
|
||||||
|
resumedFromTi2 = false
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.targenLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wizard.setTarget(
|
||||||
|
basename: config.basename,
|
||||||
|
workingDirectory: config.workingDirectory)
|
||||||
|
wizard.refreshGating()
|
||||||
|
wizard.showNotice("Target generated: \(url.lastPathComponent)")
|
||||||
|
wizard.go(to: .layOutPrint)
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
targenRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Issue 8: resume an existing target
|
||||||
|
|
||||||
|
/// `#btn-import-dataset` — open a measured dataset, write a canonical
|
||||||
|
/// `.ti3` to the working directory, and set the target (issue #30).
|
||||||
|
func importMeasurementDataset() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.datasetImportURL
|
||||||
|
: fileDialogs.selectDatasetFile()
|
||||||
|
guard let url else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let dataset = try CGATSParser.parse(url: url)
|
||||||
|
guard let directory = targetDirectory ?? wizard.effectiveWorkingDirectory else {
|
||||||
|
wizard.showNotice("Choose a working directory before importing.", kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = url.deletingPathExtension().lastPathComponent
|
||||||
|
let output = directory.appendingPathComponent("\(stem).ti3")
|
||||||
|
try CGATSWriter.write(dataset, to: output)
|
||||||
|
|
||||||
|
wizard.setTarget(basename: stem, workingDirectory: directory)
|
||||||
|
wizard.refreshGating()
|
||||||
|
wizard.showNotice("Imported \(dataset.samples.count) patches from \(url.lastPathComponent)")
|
||||||
|
|
||||||
|
if wizard.isUnlocked(.verifyInstall) {
|
||||||
|
wizard.go(to: .verifyInstall)
|
||||||
|
} else if wizard.isUnlocked(.buildProfile) {
|
||||||
|
wizard.go(to: .buildProfile)
|
||||||
|
} else {
|
||||||
|
wizard.showNotice("Imported dataset is not ready for profiling.", kind: .warning)
|
||||||
|
}
|
||||||
|
} catch let error as CGATSParseError {
|
||||||
|
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice("Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103).
|
||||||
|
/// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but
|
||||||
|
/// only when the sibling `.ti1` exists so the artefact gate holds.
|
||||||
|
func openExistingTarget() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.existingTargetURL
|
||||||
|
: fileDialogs.selectExistingTarget()
|
||||||
|
guard let url else { return }
|
||||||
|
|
||||||
|
let stem = url.deletingPathExtension().lastPathComponent
|
||||||
|
let dir = url.deletingLastPathComponent()
|
||||||
|
guard PathSecurity.isValidBasename(stem) else {
|
||||||
|
wizard.showNotice("Invalid target name.", kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch url.pathExtension.lowercased() {
|
||||||
|
case "ti1":
|
||||||
|
wizard.setTarget(basename: stem, workingDirectory: dir)
|
||||||
|
wizard.refreshGating()
|
||||||
|
resumedFromTi2 = false
|
||||||
|
measurement.resumedFromTi2 = false
|
||||||
|
wizard.go(to: .layOutPrint)
|
||||||
|
case "ti2":
|
||||||
|
let header = Ti2Header.parse(url)
|
||||||
|
guard header.hasSiblingTi1 else {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.",
|
||||||
|
kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wizard.setTarget(basename: stem, workingDirectory: dir)
|
||||||
|
wizard.refreshGating()
|
||||||
|
resumedFromTi2 = true
|
||||||
|
measurement.resumedFromTi2 = true
|
||||||
|
wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil)
|
||||||
|
wizard.go(to: .measure)
|
||||||
|
default:
|
||||||
|
wizard.showNotice(
|
||||||
|
"Not a target file — choose a .ti1 or .ti2.", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 2: create layout
|
||||||
|
|
||||||
|
func buildPrinttargConfig() -> PrinttargConfig {
|
||||||
|
PrinttargConfig(
|
||||||
|
instrument: instrument,
|
||||||
|
pageSize: pageSize,
|
||||||
|
customPageWidth: customPageW,
|
||||||
|
customPageHeight: customPageH,
|
||||||
|
bitDepth: bitDepth,
|
||||||
|
dpi: tiffDpi,
|
||||||
|
layoutOrder: layoutOrder,
|
||||||
|
customSeed: customSeed,
|
||||||
|
label: PrinttargLabel.resolved(
|
||||||
|
customLabel: labelIsCustom ? customLabel : nil,
|
||||||
|
basename: wizard.basename,
|
||||||
|
metadata: labelMetadata),
|
||||||
|
basename: wizard.basename,
|
||||||
|
workingDirectory: wizard.effectiveWorkingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createLayout() {
|
||||||
|
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
||||||
|
let config = buildPrinttargConfig()
|
||||||
|
printtargRunning = true
|
||||||
|
printtargLog = []
|
||||||
|
printtargResult = nil
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.printtargLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnAdvanceToStage3` — manual advance once `.ti2` exists.
|
||||||
|
func advanceToStage3() {
|
||||||
|
wizard.refreshGating()
|
||||||
|
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
|
||||||
|
Task { @MainActor in
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnPrintPage-N` — one TIFF.
|
||||||
|
func printPage(_ page: GalleryPage) {
|
||||||
|
guard !isPrinting else { return }
|
||||||
|
isPrinting = true
|
||||||
|
Task { @MainActor in
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
presets = environment.presetStore.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies every Stage 1/2 field of the preset to the live form
|
||||||
|
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
||||||
|
func applyPreset(_ preset: ProfilingPreset) {
|
||||||
|
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
|
||||||
|
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
|
||||||
|
customPatchCount = preset.patchCount
|
||||||
|
whitePatches = preset.whitePatches
|
||||||
|
blackPatches = preset.blackPatches
|
||||||
|
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
|
||||||
|
singleChannelSteps = preset.singleChannelSteps ?? 5
|
||||||
|
singleChannelEnabled = preset.singleChannelSteps != nil
|
||||||
|
neutralSteps = preset.neutralSteps ?? 3
|
||||||
|
neutralStepsEnabled = preset.neutralSteps != nil
|
||||||
|
neutralConcentration = preset.neutralConcentration ?? 0.50
|
||||||
|
neutralConcEnabled = preset.neutralConcentration != nil
|
||||||
|
preconditioningProfile = preset.preconditioningProfile
|
||||||
|
highQuality = preset.ofpsHighQuality == true
|
||||||
|
adaptation = preset.ofpsAdaptation ?? 0.10
|
||||||
|
adaptationEnabled = preset.ofpsAdaptation != nil
|
||||||
|
algorithm = preset.fullSpreadAlgorithm
|
||||||
|
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
|
||||||
|
totalInkLimit = preset.totalInkLimit ?? 320
|
||||||
|
inkLimitEnabled = preset.totalInkLimit != nil
|
||||||
|
darkEmphasis = preset.darkEmphasis ?? 1.0
|
||||||
|
darkEmphasisEnabled = preset.darkEmphasis != nil
|
||||||
|
devicePower = preset.devicePower ?? 1.0
|
||||||
|
devicePowerEnabled = preset.devicePower != nil
|
||||||
|
|
||||||
|
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
|
||||||
|
if let size = PageSize(rawValue: preset.pageSize) {
|
||||||
|
pageSize = size
|
||||||
|
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
|
||||||
|
pageSize = .custom; customPageW = w; customPageH = h
|
||||||
|
} else {
|
||||||
|
pageSize = .a4
|
||||||
|
}
|
||||||
|
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
|
||||||
|
tiffDpi = preset.dpi
|
||||||
|
if preset.noRandomize == true {
|
||||||
|
layoutOrder = .raster
|
||||||
|
} else if (preset.randomSeed ?? 1) == 1 {
|
||||||
|
layoutOrder = .deterministic
|
||||||
|
} else {
|
||||||
|
layoutOrder = .customSeed
|
||||||
|
}
|
||||||
|
customSeed = preset.randomSeed ?? 1
|
||||||
|
|
||||||
|
profile.applyPreset(preset)
|
||||||
|
|
||||||
|
selectedPresetID = preset.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the live Stage 1/2 form as a custom preset.
|
||||||
|
func saveCurrentAsPreset() {
|
||||||
|
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty else {
|
||||||
|
wizard.showNotice("Preset needs a name.", kind: .warning)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let preset = ProfilingPreset(
|
||||||
|
id: "custom-\(UUID().uuidString.lowercased())",
|
||||||
|
name: name,
|
||||||
|
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
|
||||||
|
patchCount: effectivePatchCount,
|
||||||
|
whitePatches: whitePatches,
|
||||||
|
blackPatches: blackPatches,
|
||||||
|
greySteps: greyStepsEnabled ? greySteps : nil,
|
||||||
|
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
|
||||||
|
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
|
||||||
|
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
|
||||||
|
preconditioningProfile: preconditioningProfile,
|
||||||
|
ofpsHighQuality: highQuality ? true : nil,
|
||||||
|
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
|
||||||
|
fullSpreadAlgorithm: algorithm.presetValue,
|
||||||
|
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
||||||
|
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
||||||
|
devicePower: devicePowerEnabled ? devicePower : nil,
|
||||||
|
instrument: instrument.rawValue,
|
||||||
|
pageSize: pageSize == .custom
|
||||||
|
? "\(Int(customPageW))x\(Int(customPageH))"
|
||||||
|
: pageSize.rawValue,
|
||||||
|
bitDepth: bitDepth.rawValue,
|
||||||
|
dpi: tiffDpi,
|
||||||
|
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
|
||||||
|
noRandomize: layoutOrder == .raster,
|
||||||
|
calibrationFile: profile.calibrationFile.isEmpty ? nil : profile.calibrationFile,
|
||||||
|
applyCalibration: profile.applyCalibration ? true : nil,
|
||||||
|
colprofAlgorithm: profile.algorithm,
|
||||||
|
colprofQuality: profile.quality,
|
||||||
|
colprofIntent: profile.intent.isEmpty ? nil : profile.intent,
|
||||||
|
colprofFwa: profile.fwaValue,
|
||||||
|
colprofIlluminant: profile.illuminant.isEmpty ? nil : profile.illuminant,
|
||||||
|
colprofObserver: profile.observer.isEmpty ? nil : profile.observer,
|
||||||
|
colprofInputViewingCond: profile.inputViewingCond.isEmpty ? nil : profile.inputViewingCond,
|
||||||
|
colprofOutputViewingCond: profile.outputViewingCond.isEmpty ? nil : profile.outputViewingCond
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try environment.presetStore.saveCustom(preset)
|
||||||
|
reloadPresets()
|
||||||
|
selectedPresetID = preset.id
|
||||||
|
showingSavePreset = false
|
||||||
|
savePresetName = ""
|
||||||
|
savePresetDesc = ""
|
||||||
|
wizard.showNotice("Preset saved: \(preset.name)")
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Could not save preset: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePreset(_ preset: ProfilingPreset) {
|
||||||
|
do {
|
||||||
|
if try environment.presetStore.deleteCustom(id: preset.id) {
|
||||||
|
if selectedPresetID == preset.id { selectedPresetID = "none" }
|
||||||
|
reloadPresets()
|
||||||
|
} else {
|
||||||
|
wizard.showNotice("Built-in presets cannot be deleted.", kind: .warning)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Could not delete preset: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importPreset() {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.presetImportURL
|
||||||
|
: fileDialogs.selectPresetFile()
|
||||||
|
guard let url else { return }
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let preset = try environment.presetStore.import(data)
|
||||||
|
try environment.presetStore.saveCustom(preset)
|
||||||
|
reloadPresets()
|
||||||
|
selectedPresetID = preset.id
|
||||||
|
wizard.showNotice("Preset imported: \(preset.name)")
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Import failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportPreset(_ preset: ProfilingPreset) {
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? UITestHooks.presetExportURL
|
||||||
|
: fileDialogs.selectPresetSavePath(name: preset.id)
|
||||||
|
guard let url else { return }
|
||||||
|
do {
|
||||||
|
try environment.presetStore.export(preset)
|
||||||
|
.write(to: url, options: .atomic)
|
||||||
|
wizard.showNotice("Preset exported: \(url.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Export failed: \(error.localizedDescription)", kind: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
|
||||||
|
let parts = raw.lowercased().split(separator: "x")
|
||||||
|
guard parts.count == 2,
|
||||||
|
let w = Double(parts[0]), let h = Double(parts[1]),
|
||||||
|
w >= 50, h >= 50 else { return nil }
|
||||||
|
return (w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,44 +2,142 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import ICCeryCore
|
import ICCeryCore
|
||||||
|
|
||||||
/// Wizard shell state (issue #1). Artefact gating, persistence and the
|
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||||
/// "open existing" flow land in issue #4.
|
///
|
||||||
|
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
|
||||||
|
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
|
||||||
|
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
|
||||||
|
/// navigation is disk, not buttons.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class WizardViewModel {
|
final class WizardViewModel {
|
||||||
/// Currently displayed stage.
|
|
||||||
var stage: WizardStage = .generate
|
// MARK: - wizardState fields (persisted)
|
||||||
|
|
||||||
|
var stage: WizardStage {
|
||||||
|
didSet { if stage != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
/// `wizardState.basename` — empty until a real artefact names it (#60).
|
||||||
|
var basename: String {
|
||||||
|
didSet { if basename != oldValue { refreshGating(); persist() } }
|
||||||
|
}
|
||||||
|
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
|
||||||
|
var workingDirectory: URL? {
|
||||||
|
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
|
||||||
|
}
|
||||||
|
var printerName: String? {
|
||||||
|
didSet { if printerName != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
var sessionMode: SessionMode {
|
||||||
|
didSet { if sessionMode != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
/// `profileBasename` may differ after a `.ti3` import (#94).
|
||||||
|
var profileBasename: String? {
|
||||||
|
didSet { if profileBasename != oldValue { persist() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ephemeral
|
||||||
|
|
||||||
/// Banner notice currently displayed (`#wizardNotification`).
|
/// Banner notice currently displayed (`#wizardNotification`).
|
||||||
var notice: Notice?
|
var notice: Notice?
|
||||||
|
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||||
|
private(set) var artefacts = StageArtefacts()
|
||||||
|
|
||||||
/// Target basename shared across stages (`targetBasename`).
|
private let stateStore: WizardStateStore
|
||||||
var basename: String = ""
|
|
||||||
|
|
||||||
/// Working directory for all Argyll artefacts.
|
|
||||||
var workingDirectory: URL?
|
|
||||||
|
|
||||||
/// Printer queue selected in Stage 2; retained across stages.
|
|
||||||
var printerName: String?
|
|
||||||
|
|
||||||
private var noticeDismissTask: Task<Void, Never>?
|
private var noticeDismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// `true` while Stage 0 (printer calibration) is shown instead of a
|
init(stateStore: WizardStateStore = WizardStateStore()) {
|
||||||
/// stepper stage.
|
self.stateStore = stateStore
|
||||||
|
let s = stateStore.load()
|
||||||
|
self.stage = s.stage
|
||||||
|
self.basename = s.basename
|
||||||
|
self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd)
|
||||||
|
self.printerName = s.printerName
|
||||||
|
self.sessionMode = s.sessionMode
|
||||||
|
self.profileBasename = s.profileBasename
|
||||||
|
refreshGating()
|
||||||
|
// A restored stage may have been locked since (#151).
|
||||||
|
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
|
||||||
|
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gating
|
||||||
|
|
||||||
|
/// `isUnlocked` for the sidebar stepper.
|
||||||
|
func isUnlocked(_ stage: WizardStage) -> Bool {
|
||||||
|
WizardGating.isUnlocked(stage, artefacts: artefacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` while Stage 0 (printer calibration) is shown.
|
||||||
var isCalibrating: Bool { stage == .calibrate }
|
var isCalibrating: Bool { stage == .calibrate }
|
||||||
|
|
||||||
func go(to stage: WizardStage) {
|
/// Re-probes the artefact directory and re-locks (#151). Called on
|
||||||
self.stage = stage
|
/// window focus, stage entry, and basename/cwd changes.
|
||||||
|
func refreshGating() {
|
||||||
|
guard !basename.isEmpty, let dir = effectiveWorkingDirectory else {
|
||||||
|
artefacts = StageArtefacts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artefacts = ArtefactProbe.verify(basename: basename, cwd: dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `setTarget(basename, cwd)` — validates the basename (no `/`, `\`,
|
||||||
|
/// `..`; no placeholders — #60) and resolves the cwd (#59).
|
||||||
|
func setTarget(basename: String, workingDirectory: URL?) {
|
||||||
|
do {
|
||||||
|
self.basename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
} catch {
|
||||||
|
showNotice("Invalid target name.", kind: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwd never stays empty once a basename exists (#59).
|
||||||
|
var effectiveWorkingDirectory: URL? {
|
||||||
|
if let workingDirectory { return workingDirectory }
|
||||||
|
return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
/// `navigateToStage(n)` — refuses locked forward moves with a
|
||||||
|
/// warning banner; backward is always allowed (docs/06).
|
||||||
|
func go(to target: WizardStage) {
|
||||||
|
guard target != .calibrate else { enterCalibration(); return }
|
||||||
|
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
|
||||||
|
stage = target
|
||||||
|
} else {
|
||||||
|
showNotice(
|
||||||
|
"Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.",
|
||||||
|
kind: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enterCalibration() {
|
func enterCalibration() {
|
||||||
|
sessionMode = .calibration
|
||||||
stage = .calibrate
|
stage = .calibrate
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitCalibration() {
|
func exitCalibration() {
|
||||||
|
sessionMode = .profile
|
||||||
stage = .generate
|
stage = .generate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
|
||||||
|
/// If the current stage re-locked, fall back to the deepest unlocked.
|
||||||
|
func windowDidBecomeKey() {
|
||||||
|
refreshGating()
|
||||||
|
if stage != .calibrate,
|
||||||
|
!WizardGating.isUnlocked(stage, artefacts: artefacts) {
|
||||||
|
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notice
|
||||||
|
|
||||||
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
|
||||||
noticeDismissTask?.cancel()
|
noticeDismissTask?.cancel()
|
||||||
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||||
@@ -59,4 +157,18 @@ final class WizardViewModel {
|
|||||||
noticeDismissTask?.cancel()
|
noticeDismissTask?.cancel()
|
||||||
notice = nil
|
notice = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
let state = WizardState(
|
||||||
|
currentStage: stage.rawValue,
|
||||||
|
basename: basename,
|
||||||
|
cwd: workingDirectory?.path ?? "",
|
||||||
|
printerName: printerName,
|
||||||
|
sessionMode: sessionMode,
|
||||||
|
profileBasename: profileBasename
|
||||||
|
)
|
||||||
|
try? stateStore.save(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("ApplycalArgs")
|
||||||
|
struct ApplycalArgsTests {
|
||||||
|
|
||||||
|
@Test("Apply argv")
|
||||||
|
func applyArgv() throws {
|
||||||
|
let config = ApplycalConfig(
|
||||||
|
calibrationPath: "/tmp/cal.cal",
|
||||||
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
|
||||||
|
)
|
||||||
|
let args = try ApplycalArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unapply is emitted when the caller explicitly sets it")
|
||||||
|
func unapplyEmittedWhenConfigSet() throws {
|
||||||
|
let config = ApplycalConfig(
|
||||||
|
calibrationPath: "/tmp/cal.cal",
|
||||||
|
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
|
||||||
|
unapply: true
|
||||||
|
)
|
||||||
|
let args = try ApplycalArgs.build(config: config)
|
||||||
|
// Builder emits -u only when the caller explicitly sets unapply.
|
||||||
|
// The UI layer never passes unapply: true in v2.0.
|
||||||
|
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
final class LogHolder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _lines: [String] = []
|
||||||
|
|
||||||
|
func append(_ batch: [String]) {
|
||||||
|
lock.lock()
|
||||||
|
_lines.append(contentsOf: batch)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines: [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return _lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ArgyllRunner colprof")
|
||||||
|
struct ArgyllRunnerColprofTests {
|
||||||
|
|
||||||
|
@Test("Mock colprof produces .icc")
|
||||||
|
func colprofProducesIcc() async throws {
|
||||||
|
let binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("ICCeryUITests/Fixtures/bin")
|
||||||
|
let testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("colprof-test-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let runner = ArgyllRunner(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: binDir)
|
||||||
|
)
|
||||||
|
|
||||||
|
let holder = LogHolder()
|
||||||
|
let config = ColprofConfig(basename: "testrun", workingDirectory: testRoot)
|
||||||
|
let url = try await runner.runColprof(config: config) { batch in
|
||||||
|
holder.append(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(url.lastPathComponent == "testrun.icc")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func tempURL(_ name: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
|
||||||
|
.appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Ti2Header")
|
||||||
|
struct Ti2HeaderTests {
|
||||||
|
@Test func parsesKeywordsAndSibling() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
try """
|
||||||
|
CTI2
|
||||||
|
TARGET_INSTRUMENT "i1iO"
|
||||||
|
NUMBER_OF_FIELDS 9
|
||||||
|
NUMBER_OF_SETS 800
|
||||||
|
NUMBER_OF_PAGES 3
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R
|
||||||
|
END_DATA_FORMAT
|
||||||
|
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
|
||||||
|
try "CGATS".write(
|
||||||
|
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
|
||||||
|
#expect(h.instrument == "i1iO")
|
||||||
|
#expect(h.patchCount == 800)
|
||||||
|
#expect(h.pageCount == 3)
|
||||||
|
#expect(h.hasSiblingTi1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingFileYieldsEmptyHeader() {
|
||||||
|
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
|
||||||
|
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func numberOfFieldsIsNotPatchCount() throws {
|
||||||
|
let url = tempURL("t.ti2")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
|
||||||
|
to: url, atomically: true, encoding: .utf8
|
||||||
|
)
|
||||||
|
#expect(Ti2Header.parse(url).patchCount == 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TiffPreview")
|
||||||
|
struct TiffPreviewTests {
|
||||||
|
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
|
||||||
|
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
|
||||||
|
let url = tempURL("big.tif")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||||
|
let ctx = CGContext(
|
||||||
|
data: nil, width: width, height: height,
|
||||||
|
bitsPerComponent: 8, bytesPerRow: width * 4,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
)!
|
||||||
|
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
let image = ctx.makeImage()!
|
||||||
|
|
||||||
|
guard let dest = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
|
||||||
|
) else { throw CocoaError(.fileWriteUnknown) }
|
||||||
|
CGImageDestinationAddImage(dest, image, nil)
|
||||||
|
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func producesCappedPNG() throws {
|
||||||
|
let tiff = try makeTiff()
|
||||||
|
let png = TiffPreview.previewPNG(tiff: tiff)
|
||||||
|
#expect(png != nil)
|
||||||
|
// PNG magic
|
||||||
|
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
|
||||||
|
// Verify the cap by decoding the thumbnail header.
|
||||||
|
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
|
||||||
|
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
|
||||||
|
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
|
||||||
|
#expect(img.width == 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonTiffReturnsNil() throws {
|
||||||
|
let url = tempURL("not-tiff.txt")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(TiffPreview.previewPNG(tiff: url) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ArtefactFiles")
|
||||||
|
struct ArtefactFilesTests {
|
||||||
|
@Test func base64RoundTrip() throws {
|
||||||
|
let url = tempURL("a.txt")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "hello".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
let b64 = try ArtefactFiles.readBase64(url)
|
||||||
|
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultWorkingDirExists() {
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: ArtefactFiles.defaultWorkingDirectory().path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("CGATS Parser & Writer")
|
||||||
|
struct CGATSParserTests {
|
||||||
|
|
||||||
|
private static let canonicalCTI3 = """
|
||||||
|
CTI3
|
||||||
|
DESCRIPTOR "Sample target"
|
||||||
|
COLOR_REP "RGB"
|
||||||
|
DEVICE_CLASS "DISPLAY"
|
||||||
|
NUMBER_OF_FIELDS 11
|
||||||
|
NUMBER_OF_SETS 2
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID\tSAMPLE_LOC\tRGB_R\tRGB_G\tRGB_B\tXYZ_X\tXYZ_Y\tXYZ_Z\tLAB_L\tLAB_A\tLAB_B
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1\tA1\t50.0\t0.0\t0.0\t20.0\t10.0\t5.0\t50.0\t60.0\t30.0
|
||||||
|
2\tA2\t0.0\t50.0\t0.0\t10.0\t30.0\t5.0\t60.0\t-50.0\t40.0
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Parses CTI3 with canonical field names")
|
||||||
|
func parseCTI3() throws {
|
||||||
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
#expect(dataset.format == .cti3)
|
||||||
|
#expect(dataset.samples.count == 2)
|
||||||
|
#expect(dataset.colorRep == "RGB")
|
||||||
|
#expect(dataset.deviceClass == "DISPLAY")
|
||||||
|
#expect(dataset.samples[0].id == "1")
|
||||||
|
#expect(dataset.samples[0].loc == "A1")
|
||||||
|
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Round-trips parse, write, reparse")
|
||||||
|
func roundTrip() throws {
|
||||||
|
let first = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
let text = try CGATSWriter.write(first)
|
||||||
|
let second = try CGATSParser.parse(text)
|
||||||
|
#expect(second.format == first.format)
|
||||||
|
#expect(second.samples.count == first.samples.count)
|
||||||
|
#expect(second.colorRep == first.colorRep)
|
||||||
|
#expect(second.deviceClass == first.deviceClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parses CSV with comma delimiters")
|
||||||
|
func parseCSV() throws {
|
||||||
|
let csv = """
|
||||||
|
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
|
||||||
|
1,A1,50,0,0,20,10,5,50,60,30
|
||||||
|
2,A2,0,50,0,10,30,5,60,-50,40
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
|
||||||
|
#expect(dataset.format == .csv)
|
||||||
|
#expect(dataset.samples.count == 2)
|
||||||
|
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Converts 0-255 device values to 0-100")
|
||||||
|
func converts255To100() throws {
|
||||||
|
let rgb = """
|
||||||
|
CTI3
|
||||||
|
COLOR_REP RGB
|
||||||
|
NUMBER_OF_FIELDS 6
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 255 128 0 50 25
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(rgb)
|
||||||
|
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
|
||||||
|
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
|
||||||
|
func synthesizesMetadata() throws {
|
||||||
|
let cmyk = """
|
||||||
|
CTI3
|
||||||
|
NUMBER_OF_FIELDS 6
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID CMYK_C CMYK_M CMYK_Y CMYK_K LAB_L
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1 50 50 50 50 50
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
let dataset = try CGATSParser.parse(cmyk)
|
||||||
|
#expect(dataset.colorRep == "CMYK")
|
||||||
|
#expect(dataset.deviceClass == "PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects empty file")
|
||||||
|
func rejectsEmpty() {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Rejects malformed arity")
|
||||||
|
func rejectsArity() {
|
||||||
|
let bad = """
|
||||||
|
CTI3
|
||||||
|
NUMBER_OF_FIELDS 2
|
||||||
|
NUMBER_OF_SETS 1
|
||||||
|
BEGIN_DATA_FORMAT
|
||||||
|
SAMPLE_ID RGB_R
|
||||||
|
END_DATA_FORMAT
|
||||||
|
BEGIN_DATA
|
||||||
|
1
|
||||||
|
END_DATA
|
||||||
|
"""
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try CGATSParser.parse(bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Writer emits valid .ti3 with tabs and required keywords")
|
||||||
|
func writerFormat() throws {
|
||||||
|
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
|
||||||
|
let text = try CGATSWriter.write(dataset)
|
||||||
|
#expect(text.contains("CTI3"))
|
||||||
|
#expect(text.contains("BEGIN_DATA_FORMAT"))
|
||||||
|
#expect(text.contains("BEGIN_DATA"))
|
||||||
|
#expect(text.contains("END_DATA"))
|
||||||
|
#expect(text.contains("COLOR_REP"))
|
||||||
|
#expect(text.contains("DEVICE_CLASS"))
|
||||||
|
#expect(text.contains("\t"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("ColprofArgs")
|
||||||
|
struct ColprofArgsTests {
|
||||||
|
|
||||||
|
@Test("Default algorithm and quality")
|
||||||
|
func defaults() throws {
|
||||||
|
let config = ColprofConfig(basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA bare -f when empty string")
|
||||||
|
func fwaBareFlag() throws {
|
||||||
|
let config = ColprofConfig(fwa: "", basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA D50 and D65 emit -f value")
|
||||||
|
func fwaD50() throws {
|
||||||
|
let config = ColprofConfig(fwa: "D50", basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(args.contains("-f"))
|
||||||
|
#expect(args.contains("D50"))
|
||||||
|
#expect(args.last == "target")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FWA none is omitted")
|
||||||
|
func fwaNoneOmitted() throws {
|
||||||
|
let config = ColprofConfig(fwa: "none", basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-f"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Viewing conditions skip none")
|
||||||
|
func viewingCondNoneSkipped() throws {
|
||||||
|
let config = ColprofConfig(
|
||||||
|
inputViewingCond: "none",
|
||||||
|
outputViewingCond: "mt",
|
||||||
|
basename: "target"
|
||||||
|
)
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-c"))
|
||||||
|
#expect(args.contains("-d"))
|
||||||
|
#expect(args.contains("mt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Description falls back to basename when empty")
|
||||||
|
func descriptionFallback() throws {
|
||||||
|
let config = ColprofConfig(description: "", basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-D"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Copyright only when non-empty")
|
||||||
|
func copyright() throws {
|
||||||
|
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(args.contains("-C"))
|
||||||
|
#expect(args.contains("Gronod 2026"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("No -u passed")
|
||||||
|
func noProgressJsonFlag() throws {
|
||||||
|
let config = ColprofConfig(basename: "target")
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
#expect(!args.contains("-u"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("ColprofProgress")
|
||||||
|
struct ColprofProgressTests {
|
||||||
|
|
||||||
|
@Test("Classifies gamut mapping")
|
||||||
|
func gamutMapping() {
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Classifies fitting or clut")
|
||||||
|
func fitting() {
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Classifies writing")
|
||||||
|
func writing() {
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
|
||||||
|
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
|
||||||
|
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
||||||
|
@Suite("CupsOptionsFilter")
|
||||||
|
struct CupsOptionsFilterTests {
|
||||||
|
|
||||||
|
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
|
||||||
|
func dropsReserved() {
|
||||||
|
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
|
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
|
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
||||||
|
+ "collate=true copies=1 job-sheets=none,none "
|
||||||
|
+ "pserrorhandler-requested=standard "
|
||||||
|
+ "MediaType=PhotographicGlossy"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Keeps relevant driver keys, order preserved")
|
||||||
|
func keepsRelevant() {
|
||||||
|
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
||||||
|
+ "Resolution=600x600dpi Duplex=None"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Permissive: unknown non-com.* keys survive")
|
||||||
|
func keepsUnknown() {
|
||||||
|
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Drops empty keys and values")
|
||||||
|
func dropsEmpty() {
|
||||||
|
let raw = "=noval MediaType= InputSlot=Rear"
|
||||||
|
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
|
||||||
|
func extractMedia() {
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "EPIJ_Medi=7") == "7")
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
||||||
|
/// `@convention(c)` closures can't capture, so recording goes through
|
||||||
|
/// a file-scope recorder keyed by global state; no private symbols are
|
||||||
|
/// touched.
|
||||||
|
@Suite("ColorSyncSuppressor")
|
||||||
|
@MainActor
|
||||||
|
struct ColorSyncSuppressorTests {
|
||||||
|
|
||||||
|
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
||||||
|
private var fakeSession: PMPrintSession {
|
||||||
|
unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call log — static since `@convention(c)` can't capture. The
|
||||||
|
/// resolver sets `currentSymbol` right before each call, so the C
|
||||||
|
/// function records (symbol, mode) without capturing `name`.
|
||||||
|
private static var recorded: [(String, String)] = []
|
||||||
|
private static var currentSymbol = ""
|
||||||
|
private static var succeeding: (String, String)?
|
||||||
|
private static var missing: Set<String> = []
|
||||||
|
|
||||||
|
private func makeSuppressor() -> ColorSyncSuppressor {
|
||||||
|
var s = ColorSyncSuppressor()
|
||||||
|
s.log = { _ in }
|
||||||
|
s.modeResolver = { name in
|
||||||
|
if Self.missing.contains(name) { return nil }
|
||||||
|
Self.currentSymbol = name
|
||||||
|
return { _, modeArg in
|
||||||
|
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
||||||
|
if let ok = Self.succeeding,
|
||||||
|
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
||||||
|
func attemptOrder() {
|
||||||
|
Self.recorded = []
|
||||||
|
Self.succeeding = nil
|
||||||
|
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
||||||
|
let s = makeSuppressor()
|
||||||
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
|
// Lock is unresolvable → skipped; the rest plays out in order.
|
||||||
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
|
||||||
|
== ColorMatchingAttempts.attempts
|
||||||
|
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
||||||
|
.map { "\($0.symbol)|\($0.mode)" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("First zero wins — later symbols/modes not called")
|
||||||
|
func firstZeroWins() {
|
||||||
|
Self.recorded = []
|
||||||
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
|
"AP_ApplicationColorMatching")
|
||||||
|
Self.missing = []
|
||||||
|
let s = makeSuppressor()
|
||||||
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
|
||||||
|
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
||||||
|
func modeFallback() {
|
||||||
|
Self.recorded = []
|
||||||
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
|
"ApplicationColorMatching")
|
||||||
|
Self.missing = []
|
||||||
|
let s = makeSuppressor()
|
||||||
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
|
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
|
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
|
||||||
|
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
|
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
|
||||||
|
#expect(Self.recorded.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("All symbols missing → false, no calls")
|
||||||
|
func allMissing() {
|
||||||
|
Self.recorded = []
|
||||||
|
Self.succeeding = nil
|
||||||
|
Self.missing = Set(ColorMatchingAttempts.symbols)
|
||||||
|
let s = makeSuppressor()
|
||||||
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
|
#expect(Self.recorded.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||||
|
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||||
|
@Suite("CupsParsers")
|
||||||
|
struct CupsParsersTests {
|
||||||
|
|
||||||
|
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||||
|
private let lpstatE = """
|
||||||
|
Canon_Pro9500_II_series_XPS
|
||||||
|
Epson_XP_55_LPD
|
||||||
|
EPSON_XP_55_Series
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let lpstatP = """
|
||||||
|
printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026
|
||||||
|
printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026
|
||||||
|
printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 -
|
||||||
|
Paused
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let lpoptionsP = """
|
||||||
|
device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let lpoptionsL = """
|
||||||
|
PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT
|
||||||
|
InputSlot/Media Source: Auto *Main Photo Rear
|
||||||
|
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||||
|
ColorModel/Output Mode: *RGB Gray
|
||||||
|
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||||
|
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("lpstat -e: one destination per line; empty = success")
|
||||||
|
func destinations() {
|
||||||
|
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
|
||||||
|
"Canon_Pro9500_II_series_XPS",
|
||||||
|
"Epson_XP_55_LPD",
|
||||||
|
"EPSON_XP_55_Series",
|
||||||
|
])
|
||||||
|
#expect(CupsParsers.lpstatDestinations("") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lpstat -p: idle / now-printing / disabled statuses")
|
||||||
|
func statuses() {
|
||||||
|
let s = CupsParsers.lpstatStatuses(lpstatP)
|
||||||
|
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
|
||||||
|
#expect(s["Epson_XP_55_LPD"] == .printing)
|
||||||
|
#expect(s["EPSON_XP_55_Series"] == .stopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lpstat -d: default destination or none")
|
||||||
|
func defaultDestination() {
|
||||||
|
#expect(CupsParsers.lpstatDefault(
|
||||||
|
"system default destination: Canon_Pro9500_II_series_XPS\n")
|
||||||
|
== "Canon_Pro9500_II_series_XPS")
|
||||||
|
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
|
||||||
|
func displayName() {
|
||||||
|
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
|
||||||
|
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lpoptions -l: key/label split, * marks the default")
|
||||||
|
func optionListings() {
|
||||||
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
|
#expect(listings.count == 6)
|
||||||
|
|
||||||
|
let page = listings[0]
|
||||||
|
#expect(page.key == "PageSize")
|
||||||
|
#expect(page.label == "Media Size")
|
||||||
|
#expect(page.defaultChoice == "A4")
|
||||||
|
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
|
||||||
|
#expect(!page.choices.contains("*A4"))
|
||||||
|
|
||||||
|
let slot = listings[1]
|
||||||
|
#expect(slot.key == "InputSlot")
|
||||||
|
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
|
||||||
|
#expect(slot.defaultChoice == "Main")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
|
||||||
|
func capabilities() {
|
||||||
|
let service = CupsService()
|
||||||
|
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||||
|
let caps = service.capabilities(from: listings, ppd: nil)
|
||||||
|
|
||||||
|
#expect(caps.trays == [
|
||||||
|
PrinterTray(id: 1, name: "Auto"),
|
||||||
|
PrinterTray(id: 2, name: "Main"),
|
||||||
|
PrinterTray(id: 3, name: "Photo"),
|
||||||
|
PrinterTray(id: 4, name: "Rear"),
|
||||||
|
])
|
||||||
|
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
|
||||||
|
#expect(caps.paperSizes.count == 10)
|
||||||
|
#expect(caps.mediaTypes.map(\.id) == [
|
||||||
|
"Stationery", "PhotographicHighGloss", "Photographic",
|
||||||
|
"PhotographicMatte", "Envelope",
|
||||||
|
])
|
||||||
|
#expect(caps.supportsOrientation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PPD enrichment maps id → human label")
|
||||||
|
func ppdLabels() {
|
||||||
|
let ppd = """
|
||||||
|
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
|
||||||
|
*CNIJMediaType 0/Plain Paper: ""
|
||||||
|
*en_US.CNIJMediaType 13/Envelope: ""
|
||||||
|
"""
|
||||||
|
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||||
|
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
|
||||||
|
#expect(labels["0"] == "Plain Paper")
|
||||||
|
#expect(labels["13"] == "Envelope")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("detectMediaTypeKey prefers vendor keys in order")
|
||||||
|
func mediaTypeKey() {
|
||||||
|
#expect(CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
|
||||||
|
#expect(CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
|
||||||
|
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
||||||
|
func driverBypass() {
|
||||||
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
}
|
||||||
|
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
|
||||||
|
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
|
||||||
|
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
|
||||||
|
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
|
||||||
|
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
|
||||||
|
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
|
||||||
|
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
|
||||||
|
#expect(pair(["PageSize"]) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
@Suite("DriftAlert")
|
||||||
|
struct DriftAlertTests {
|
||||||
|
|
||||||
|
@Test("No alert with fewer than two poor results")
|
||||||
|
func notEnough() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 4.0, at: 1000)
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alert on two poor results one hour apart")
|
||||||
|
func oneHourApart() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 4.0, at: 1000),
|
||||||
|
record(avg: 5.0, at: 4600)
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("No alert if same day and under one hour")
|
||||||
|
func sameDayUnderHour() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 4.0, at: 1000),
|
||||||
|
record(avg: 5.0, at: 2000)
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alert on distinct days")
|
||||||
|
func distinctDays() {
|
||||||
|
let day1 = record(avg: 4.0, at: 0)
|
||||||
|
let day2 = record(avg: 5.0, at: 86400 + 1000)
|
||||||
|
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Non-poor records do not trigger")
|
||||||
|
func nonPoor() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 1.0, at: 0),
|
||||||
|
record(avg: 1.5, at: 86400)
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Non-poor records break the consecutive poor run")
|
||||||
|
func nonPoorBreaksRun() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 4.0, at: 0), // poor
|
||||||
|
record(avg: 4.5, at: 86400), // poor, far apart
|
||||||
|
record(avg: 1.0, at: 90000), // good — breaks the run
|
||||||
|
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Only the final consecutive poor run is considered")
|
||||||
|
func onlySuffixRun() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 4.0, at: 0), // poor
|
||||||
|
record(avg: 4.5, at: 18000), // poor, > 1h from first
|
||||||
|
record(avg: 1.0, at: 20000), // good — breaks the run
|
||||||
|
record(avg: 4.0, at: 25000), // poor
|
||||||
|
record(avg: 4.5, at: 26000) // poor, < 1h and same day
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Final consecutive poor run alerts when far apart")
|
||||||
|
func suffixRunAlerts() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 1.0, at: 0), // good
|
||||||
|
record(avg: 4.0, at: 1000), // poor
|
||||||
|
record(avg: 4.5, at: 4600) // poor, 1h after previous
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A single final poor record after good records does not alert")
|
||||||
|
func singleFinalPoor() {
|
||||||
|
let records = [
|
||||||
|
record(avg: 1.0, at: 0),
|
||||||
|
record(avg: 4.0, at: 86400)
|
||||||
|
]
|
||||||
|
#expect(DriftAlert.compute(from: records) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
|
||||||
|
VerificationRecord(
|
||||||
|
id: "vr-\(Int(offset))",
|
||||||
|
profileName: "p",
|
||||||
|
printerName: "",
|
||||||
|
avgDE: avg,
|
||||||
|
maxDE: avg,
|
||||||
|
rmsDE: avg,
|
||||||
|
patchCount: 1,
|
||||||
|
status: VerificationStatus.from(avgDE: avg),
|
||||||
|
timestamp: Date(timeIntervalSince1970: offset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-files-\(name)")
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ url: URL, _ contents: String = "x") throws {
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("PathSecurity")
|
||||||
|
struct PathSecurityTests {
|
||||||
|
@Test func rejectsTraversalAndSeparators() {
|
||||||
|
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
|
||||||
|
#expect(!PathSecurity.isValidBasename(bad))
|
||||||
|
#expect(throws: PathSecurity.Error.self) {
|
||||||
|
try PathSecurity.sanitizeBasename(bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func acceptsNormalNames() {
|
||||||
|
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
|
||||||
|
#expect(PathSecurity.isValidBasename(good))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveSafeCwdPrefersExplicit() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveSafeCwdNeverReturnsNil() {
|
||||||
|
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
|
||||||
|
let resolved = PathSecurity.resolveSafeCwd(missing)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: resolved.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("AtomicFileWriter")
|
||||||
|
struct AtomicFileWriterTests {
|
||||||
|
@Test func writesAndLeavesNoTmp() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("state.json")
|
||||||
|
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overwritesExistingAtomically() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("f.txt")
|
||||||
|
try AtomicFileWriter.write("one", to: url)
|
||||||
|
try AtomicFileWriter.write("two-longer", to: url)
|
||||||
|
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func createsParentDirs() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
let url = dir.appendingPathComponent("a/b/c/deep.json")
|
||||||
|
try AtomicFileWriter.write("{}", to: url)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ArtefactProbe")
|
||||||
|
struct ArtefactProbeTests {
|
||||||
|
@Test func verifyProgression() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v == StageArtefacts())
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.ti1"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.ti2"))
|
||||||
|
try touch(dir.appendingPathComponent("t.ti3"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
|
||||||
|
|
||||||
|
try touch(dir.appendingPathComponent("t.icc"))
|
||||||
|
v = ArtefactProbe.verify(basename: "t", cwd: dir)
|
||||||
|
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func icmWinsOverIcc() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
try touch(dir.appendingPathComponent("p.icc"))
|
||||||
|
try touch(dir.appendingPathComponent("p.icm"))
|
||||||
|
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
|
||||||
|
#expect(profile?.pathExtension == "icm")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func enumeratesPassesPagesAndCAL() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
for name in [
|
||||||
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
|
"t.icc", "t.gam",
|
||||||
|
"CAL_t.ti1", "CAL_t.cal",
|
||||||
|
// must NOT match:
|
||||||
|
"other.ti1", "t.txt", "CAL_other.ti1",
|
||||||
|
] { try touch(dir.appendingPathComponent(name)) }
|
||||||
|
|
||||||
|
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
|
||||||
|
.map(\.lastPathComponent)
|
||||||
|
for expected in [
|
||||||
|
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
|
||||||
|
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
|
||||||
|
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
|
||||||
|
] {
|
||||||
|
#expect(names.contains(expected), "missing \(expected)")
|
||||||
|
}
|
||||||
|
#expect(!names.contains("other.ti1"))
|
||||||
|
#expect(!names.contains("t.txt"))
|
||||||
|
#expect(!names.contains("CAL_other.ti1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyDirReturnsEmpty() throws {
|
||||||
|
let dir = try tempDir()
|
||||||
|
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user