Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 | ||
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
14f521a65e | ||
|
|
4281d07754 | ||
|
|
73db1c8c25 | ||
|
|
7c1303ac11 | ||
|
|
797d30b023 | ||
|
|
65ba6dc61a | ||
|
|
c0f5fb8c28 | ||
|
|
dc62f5c016 | ||
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc | ||
|
|
716b302374 | ||
|
|
20d6bf7fdc |
@@ -0,0 +1,207 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from `ArgyllRunner` executions.
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable {
|
||||
case processFailed(code: Int32, logs: [String])
|
||||
case missingArtefact(String)
|
||||
case malformedManifest(String)
|
||||
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
private struct CollectedRun {
|
||||
var exitCode: Int32?
|
||||
var stdout: 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).
|
||||
private func collect(
|
||||
id processId: String,
|
||||
events: AsyncStream<ProcessEvent>,
|
||||
onLogBatch: (@Sendable ([String]) -> Void)?
|
||||
) async -> CollectedRun {
|
||||
var lines: [String] = []
|
||||
var stdout = ""
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
|
||||
///
|
||||
/// Order:
|
||||
/// 1. Settings `argyll_binary_dir` override — only if `<dir>/<name>`
|
||||
/// exists there.
|
||||
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
|
||||
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
|
||||
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
|
||||
/// 3. If nothing exists the *constructed* bundled path is still returned
|
||||
/// — a missing binary surfaces later as `process:error` on spawn,
|
||||
/// matching v1 semantics.
|
||||
public struct BinaryResolver: Sendable {
|
||||
|
||||
/// Root that contains the platform dirs — `Bundle.resource/Argyll` in
|
||||
/// the app, a fixture dir in tests.
|
||||
public let bundledRoot: URL
|
||||
/// `settings.argyll_binary_dir`, already expanded to a URL.
|
||||
public let overrideDir: URL?
|
||||
/// Host architecture directory names, universal preferred.
|
||||
public let archDirs: [String]
|
||||
|
||||
public init(
|
||||
bundledRoot: URL = AppPaths.bundledArgyllDir,
|
||||
overrideDir: URL? = nil,
|
||||
archDirs: [String]? = nil
|
||||
) {
|
||||
self.bundledRoot = bundledRoot
|
||||
self.overrideDir = overrideDir
|
||||
#if arch(arm64)
|
||||
let fallback = ["macos-arm64", "macos-aarch64"]
|
||||
#else
|
||||
let fallback = ["macos-x86_64"]
|
||||
#endif
|
||||
self.archDirs = archDirs ?? ["macos-universal"] + fallback
|
||||
}
|
||||
|
||||
/// Marker used to decide whether `macos-universal` is usable.
|
||||
public static let markerBinary = "instlist"
|
||||
|
||||
/// Resolves a tool name to an absolute URL (never throws — see type
|
||||
/// docs). `name` is the bare tool name, e.g. `"targen"`.
|
||||
public func resolve(_ name: String) -> URL {
|
||||
let fm = FileManager.default
|
||||
|
||||
if let dir = overrideDir {
|
||||
let candidate = dir.appendingPathComponent(name)
|
||||
if fm.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return bundledRoot
|
||||
.appendingPathComponent(platformDir(), isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// The bundled platform directory that resolution will use.
|
||||
public func platformDir() -> String {
|
||||
let fm = FileManager.default
|
||||
let universal = bundledRoot.appendingPathComponent("macos-universal")
|
||||
if fm.fileExists(
|
||||
atPath: universal.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return "macos-universal"
|
||||
}
|
||||
for dir in archDirs where dir != "macos-universal" {
|
||||
if fm.fileExists(
|
||||
atPath: bundledRoot
|
||||
.appendingPathComponent(dir)
|
||||
.appendingPathComponent(Self.markerBinary).path
|
||||
) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
// Nothing present — still return the preferred dir so the error
|
||||
// message points at where the user should drop binaries.
|
||||
return archDirs.first ?? "macos-universal"
|
||||
}
|
||||
|
||||
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
|
||||
public func mock(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("mocks", isDirectory: true)
|
||||
.appendingPathComponent("\(name).mock", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
|
||||
public func referenceGamut(_ name: String) -> URL {
|
||||
bundledRoot
|
||||
.appendingPathComponent("reference_gamuts", isDirectory: true)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
}
|
||||
|
||||
/// Whether the resolved path exists and is executable.
|
||||
public func exists(_ url: URL) -> Bool {
|
||||
FileManager.default.isExecutableFile(atPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@
|
||||
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 for the About dialog.
|
||||
public static func appInfo(
|
||||
bundle: Bundle = .main
|
||||
) -> (version: String, build: String) {
|
||||
let info = bundle.infoDictionary ?? [:]
|
||||
return (
|
||||
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
|
||||
info["CFBundleVersion"] as? String ?? "0"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
switch self {
|
||||
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;
|
||||
/// issue #5 adds the rolling file sink and runtime `setLevel`.
|
||||
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
|
||||
/// gated at write time so a settings save takes effect immediately
|
||||
/// (#158).
|
||||
public struct AppLogger: Sendable {
|
||||
public static let shared = AppLogger(category: "app")
|
||||
|
||||
private let osLog: Logger
|
||||
private let sink: LogSink
|
||||
public let category: String
|
||||
|
||||
public init(category: String) {
|
||||
public init(category: String, sink: LogSink = .shared) {
|
||||
self.category = category
|
||||
self.sink = sink
|
||||
self.osLog = Logger(
|
||||
subsystem: AppPaths.bundleIdentifier,
|
||||
category: category
|
||||
@@ -44,8 +48,11 @@ public struct AppLogger: Sendable {
|
||||
|
||||
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
|
||||
let text = LogSanitizer.sanitize(message())
|
||||
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 warn(_ message: @autoclosure () -> String) { log(.warn, 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)
|
||||
}
|
||||
}
|
||||
@@ -14,20 +14,41 @@ public enum AppPaths {
|
||||
}
|
||||
|
||||
/// `~/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 {
|
||||
FileManager.default
|
||||
#if DEBUG
|
||||
if let root = testRoot {
|
||||
return root.appendingPathComponent("AppData", isDirectory: true)
|
||||
}
|
||||
#endif
|
||||
return FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `~/Library/Logs/com.gronod.iccery2`
|
||||
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]
|
||||
.appendingPathComponent("Logs", 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`
|
||||
public static var logFile: URL {
|
||||
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).
|
||||
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 printcal(_ stem: String) -> String { "printcal_\(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)" }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Captured output from `runCaptured` (used by printcal/applycal —
|
||||
/// the only tools whose results arrive as one-shot output).
|
||||
/// Captured output from `runCaptured` — one-shot tools whose results
|
||||
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
|
||||
public struct CapturedResult: Sendable, Equatable {
|
||||
public let stdout: String
|
||||
public let stderr: String
|
||||
@@ -30,35 +30,52 @@ public actor ProcessManager {
|
||||
|
||||
// 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] = [:]
|
||||
|
||||
func add(_ continuation: AsyncStream<ProcessEvent>.Continuation, token: UUID) {
|
||||
lock.lock()
|
||||
map[token] = continuation
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func remove(_ token: UUID) {
|
||||
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 nonisolated let subscriberBox = SubscriberBox()
|
||||
|
||||
/// Subscribe to the event bus. Each call returns an independent
|
||||
/// 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.
|
||||
public nonisolated func events() -> AsyncStream<ProcessEvent> {
|
||||
AsyncStream { continuation in
|
||||
let box = subscriberBox
|
||||
let token = UUID()
|
||||
Task { await self.addSubscriber(continuation, token: token) }
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.removeSubscriber(token) }
|
||||
}
|
||||
return AsyncStream { continuation in
|
||||
box.add(continuation, token: token)
|
||||
continuation.onTermination = { _ in box.remove(token) }
|
||||
}
|
||||
}
|
||||
|
||||
private func addSubscriber(
|
||||
_ continuation: AsyncStream<ProcessEvent>.Continuation,
|
||||
token: UUID
|
||||
) {
|
||||
subscribers[token] = continuation
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ token: UUID) {
|
||||
subscribers.removeValue(forKey: token)
|
||||
}
|
||||
|
||||
private func emit(_ event: ProcessEvent) {
|
||||
for continuation in subscribers.values {
|
||||
continuation.yield(event)
|
||||
}
|
||||
private nonisolated func emit(_ event: ProcessEvent) {
|
||||
subscriberBox.yield(event)
|
||||
}
|
||||
|
||||
// MARK: - Child registry
|
||||
@@ -156,7 +173,8 @@ public actor ProcessManager {
|
||||
|
||||
/// Runs a child to completion and returns all output. Reads stdout
|
||||
/// 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(
|
||||
id: String,
|
||||
binary: URL,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Mock script for chartread -u
|
||||
# This script simulates the behaviour of chartread for testing purposes.
|
||||
|
||||
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||
IS_XY=0
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--xy" ]; then
|
||||
IS_XY=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read -r _calib
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Please place sheet 1 of 1 on the table"
|
||||
echo "hit return to continue, Esc or 'q' to give up"
|
||||
read -r _sheet1
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid1
|
||||
|
||||
echo "locate patch B24 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read -r _fid2
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
sleep 0.5
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
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
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
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 "Please remove last sheet from table"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handheld / strip reader simulation
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
|
||||
# We don't really wait for input, just wait 1 second
|
||||
sleep 1
|
||||
echo "Calibration successful."
|
||||
echo "Hit [Space] to read strip A (or 's' to skip)."
|
||||
|
||||
sleep 1
|
||||
echo "Reading strip A..."
|
||||
|
||||
# Emit mock JSON for strip A
|
||||
cat << 'EOF'
|
||||
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..."
|
||||
|
||||
# Emit mock JSON for strip B
|
||||
cat << 'EOF'
|
||||
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]}}]}
|
||||
EOF
|
||||
|
||||
echo "Ready to read... done."
|
||||
exit 0
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Mock script for colprof
|
||||
# Simulates colprof execution and outputs progress log
|
||||
|
||||
basename="$1"
|
||||
# Find last argument if -D or other flags are used
|
||||
for arg in "$@"; do
|
||||
basename="$arg"
|
||||
done
|
||||
|
||||
echo "colprof: Starting profile calculation for $basename"
|
||||
sleep 1
|
||||
echo "Gamut mapping calculation..."
|
||||
sleep 1
|
||||
echo "Fitting cLUT grid points..."
|
||||
sleep 1
|
||||
echo "Writing ICC profile $basename.icc..."
|
||||
touch "$basename.icc"
|
||||
echo "Done."
|
||||
exit 0
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Mock script for profcheck
|
||||
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||
|
||||
echo "profcheck: Checking profile accuracy..."
|
||||
echo "No of test patches = 52"
|
||||
sleep 1
|
||||
cat << 'EOF'
|
||||
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||
EOF
|
||||
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||
exit 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
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
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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") }
|
||||
/// 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,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
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import AppKit
|
||||
import ICCeryCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ICCeryApp: App {
|
||||
@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 {
|
||||
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
|
||||
Window("ICCery", id: "main") {
|
||||
RootView(model: model)
|
||||
RootView(workflow: workflow)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -19,15 +29,24 @@ struct ICCeryApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// AppDelegate: quit when the single window closes, and give later
|
||||
/// milestones a hook to `killAll` Argyll children before teardown
|
||||
/// (#147/#149 — wired once ProcessManager exists in #2).
|
||||
/// AppDelegate: quit when the single window closes, and `killAll` Argyll
|
||||
/// children before teardown (#147/#149). Termination is deferred until
|
||||
/// `killAll` has signaled every child so `chartread` can park an XY head
|
||||
/// when the UI already sent `q\n`.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var terminationRequested = false
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
// Issue #2+: ProcessManager.shared.killAll()
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !terminationRequested else { return .terminateNow }
|
||||
terminationRequested = true
|
||||
Task {
|
||||
await ProcessManager.shared.killAll()
|
||||
NSApplication.shared.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ struct NoticeBanner: View {
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(3)
|
||||
.accessibilityIdentifier("noticeText")
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Root layout: 270 pt sidebar + main stage area with the notification
|
||||
/// banner pinned to the top (docs/21 §Shell).
|
||||
struct RootView: View {
|
||||
@Bindable var model: WizardViewModel
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
@State private var showingSettings = false
|
||||
@State private var showingAbout = false
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
SidebarView(
|
||||
model: model,
|
||||
workflow: workflow,
|
||||
onOpenSettings: { showingSettings = true },
|
||||
onOpenAbout: { showingAbout = true }
|
||||
)
|
||||
@@ -23,21 +27,26 @@ struct RootView: View {
|
||||
if let notice = model.notice {
|
||||
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
||||
}
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
stageContent
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.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) {
|
||||
// Full settings dialog lands in issue #5.
|
||||
VStack(spacing: 12) {
|
||||
Text("Settings").font(.headline)
|
||||
Text("Implemented in issue #5.")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Close") { showingSettings = false }
|
||||
SettingsView()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 420)
|
||||
.sheet(isPresented: $workflow.showingSavePreset) {
|
||||
SavePresetDialog(workflow: workflow)
|
||||
}
|
||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||
ManagePresetsDialog(workflow: workflow)
|
||||
}
|
||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||
Button("OK") {}
|
||||
@@ -45,4 +54,32 @@ struct RootView: View {
|
||||
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var stageContent: some View {
|
||||
switch model.stage {
|
||||
case .generate:
|
||||
Stage1View(workflow: workflow)
|
||||
case .layOutPrint:
|
||||
Stage2View(workflow: workflow)
|
||||
case .measure:
|
||||
// Stage 3 stays a shell until M4, but a .ti2 resume still
|
||||
// lands here — show the persisted state (#8, issue #140).
|
||||
VStack(spacing: 16) {
|
||||
if workflow.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")
|
||||
StagePlaceholderView(stage: model.stage)
|
||||
}
|
||||
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,10 +4,12 @@ import ICCeryCore
|
||||
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
|
||||
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
|
||||
struct SidebarView: View {
|
||||
@Bindable var model: WizardViewModel
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
@@ -31,16 +33,38 @@ struct SidebarView: View {
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
// Preset select (`#presetSelect`). Disabled until the preset
|
||||
// engine lands in issue #11.
|
||||
Picker("Preset", selection: .constant("none")) {
|
||||
// Preset select (`#presetSelect`) — issue #11. Selection
|
||||
// applies the preset immediately; names render via Text only.
|
||||
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")
|
||||
ForEach(workflow.presets) { preset in
|
||||
Text(preset.name).tag(preset.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.disabled(true)
|
||||
.accessibilityIdentifier("presetSelect")
|
||||
.padding(.horizontal, 12)
|
||||
.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
|
||||
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
|
||||
Button(action: { model.enterCalibration() }) {
|
||||
@@ -60,8 +84,8 @@ struct SidebarView: View {
|
||||
StepperRow(
|
||||
stage: stage,
|
||||
isActive: model.stage == stage,
|
||||
// Only Stage 1 until artefact gating lands in #4.
|
||||
isEnabled: stage == .generate
|
||||
// Artefact gating (issue #4) — disk is truth.
|
||||
isEnabled: model.isUnlocked(stage)
|
||||
) {
|
||||
model.go(to: stage)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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…") { /* CGATS import — #94, later */ }
|
||||
.accessibilityIdentifier("btn-import-dataset")
|
||||
.disabled(true)
|
||||
}
|
||||
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,378 @@
|
||||
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")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("printNotification")
|
||||
|
||||
// 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,636 @@
|
||||
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 = ""
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
self.wizard = WizardViewModel(stateStore: environment.stateStore)
|
||||
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
|
||||
|
||||
/// `#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
|
||||
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
|
||||
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.
|
||||
/// `#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
|
||||
|
||||
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
|
||||
)
|
||||
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 ICCeryCore
|
||||
|
||||
/// Wizard shell state (issue #1). Artefact gating, persistence and the
|
||||
/// "open existing" flow land in issue #4.
|
||||
/// Wizard state machine + artefact gating (issue #4, docs/06).
|
||||
///
|
||||
/// `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
|
||||
@Observable
|
||||
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`).
|
||||
var notice: Notice?
|
||||
/// Current artefact probe result; recomputed on `refreshGating()`.
|
||||
private(set) var artefacts = StageArtefacts()
|
||||
|
||||
/// Target basename shared across stages (`targetBasename`).
|
||||
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 let stateStore: WizardStateStore
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
|
||||
/// `true` while Stage 0 (printer calibration) is shown instead of a
|
||||
/// stepper stage.
|
||||
init(stateStore: WizardStateStore = WizardStateStore()) {
|
||||
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 }
|
||||
|
||||
func go(to stage: WizardStage) {
|
||||
self.stage = stage
|
||||
/// Re-probes the artefact directory and re-locks (#151). Called on
|
||||
/// 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() {
|
||||
sessionMode = .calibration
|
||||
stage = .calibrate
|
||||
}
|
||||
|
||||
func exitCalibration() {
|
||||
sessionMode = .profile
|
||||
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) {
|
||||
noticeDismissTask?.cancel()
|
||||
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
|
||||
@@ -59,4 +157,18 @@ final class WizardViewModel {
|
||||
noticeDismissTask?.cancel()
|
||||
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,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,83 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("BinaryResolver")
|
||||
struct BinaryResolverTests {
|
||||
|
||||
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try body(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private func touch(_ url: URL, executable: Bool = true) throws {
|
||||
FileManager.default.createFile(atPath: url.path, contents: Data())
|
||||
if executable {
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func overrideDirWinsWhenFileExists() throws {
|
||||
let override = try makeTree { root in
|
||||
try touch(root.appendingPathComponent("targen"))
|
||||
}
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
|
||||
}
|
||||
|
||||
@Test func overrideFallsThroughWhenMissing() throws {
|
||||
let override = try makeTree { _ in }
|
||||
let bundled = try makeTree { root in
|
||||
let dir = root.appendingPathComponent("macos-universal")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try touch(dir.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
|
||||
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
|
||||
}
|
||||
|
||||
@Test func universalPreferredWhenMarkerPresent() throws {
|
||||
let bundled = try makeTree { root in
|
||||
for dir in ["macos-universal", "macos-x86_64"] {
|
||||
let d = root.appendingPathComponent(dir)
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
}
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
#expect(r.platformDir() == "macos-universal")
|
||||
}
|
||||
|
||||
@Test func fallsBackToArchDir() throws {
|
||||
let bundled = try makeTree { root in
|
||||
let d = root.appendingPathComponent("macos-x86_64")
|
||||
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
|
||||
try touch(d.appendingPathComponent("instlist"))
|
||||
}
|
||||
let r = BinaryResolver(
|
||||
bundledRoot: bundled,
|
||||
archDirs: ["macos-universal", "macos-x86_64"]
|
||||
)
|
||||
#expect(r.platformDir() == "macos-x86_64")
|
||||
}
|
||||
|
||||
@Test func missingEverythingReturnsConstructedPath() throws {
|
||||
let bundled = try makeTree { _ in }
|
||||
let r = BinaryResolver(bundledRoot: bundled)
|
||||
// v1 semantic: path is returned; spawn surfaces the error.
|
||||
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
|
||||
#expect(!r.exists(r.resolve("targen")))
|
||||
}
|
||||
|
||||
@Test func mockAndGamutPaths() throws {
|
||||
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
|
||||
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
||||
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
||||
/// flag order, captured-option precedence, and sanitisation.
|
||||
@Suite("LpArgs")
|
||||
struct LpArgsTests {
|
||||
|
||||
private let tiff = "/tmp/work/target_001.tif"
|
||||
private let queue = "EPSON_XP_55_Series"
|
||||
|
||||
private func build(
|
||||
options: PrintOptions = PrintOptions(),
|
||||
optionKeys: Set<String> = []
|
||||
) throws -> [String] {
|
||||
try LpArgs.build(
|
||||
queue: queue, tiffPath: tiff,
|
||||
options: options, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
|
||||
func header() throws {
|
||||
let argv = try build()
|
||||
#expect(Array(argv[0...1]) == ["-d", queue])
|
||||
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
|
||||
#expect(Array(argv[4...5])
|
||||
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||
#expect(Array(argv[6...7])
|
||||
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||
#expect(argv.last == tiff)
|
||||
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
|
||||
}
|
||||
|
||||
@Test("Never emits -o raw; captured raw= is dropped")
|
||||
func neverRaw() throws {
|
||||
let argv = try build(options: PrintOptions(
|
||||
cupsOptions: "raw=true MediaType=Photo"))
|
||||
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||
#expect(argv[i + 1] != "raw")
|
||||
#expect(argv[i + 1] != "raw=true")
|
||||
}
|
||||
#expect(!argv.contains { $0.hasPrefix("raw=") })
|
||||
#expect(argv.contains("MediaType=Photo"))
|
||||
}
|
||||
|
||||
@Test("Captured options replayed after AP_* headers")
|
||||
func capturedReplay() throws {
|
||||
let argv = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
||||
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
||||
let apFirst = argv.firstIndex(of:
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
||||
#expect(rear > apFirst)
|
||||
}
|
||||
|
||||
@Test("Captured wins: media key present → derived media skipped")
|
||||
func capturedWinsMedia() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(
|
||||
mediaType: "Plain",
|
||||
cupsOptions: "MediaType=Glossy"),
|
||||
optionKeys: ["MediaType"])
|
||||
#expect(argv.contains("MediaType=Glossy"))
|
||||
#expect(!argv.contains("MediaType=Plain"))
|
||||
}
|
||||
|
||||
@Test("Media emitted via detected key when not captured")
|
||||
func mediaDerived() throws {
|
||||
let argv = try build(
|
||||
options: PrintOptions(mediaType: "SemiGloss"),
|
||||
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||
// CNIJMediaType wins over MediaType in detection order.
|
||||
#expect(argv.contains("CNIJMediaType=SemiGloss"))
|
||||
#expect(!argv.contains("MediaType=SemiGloss"))
|
||||
}
|
||||
|
||||
@Test("Driver bypass emitted when absent, skipped when captured")
|
||||
func bypassRules() throws {
|
||||
let withBypass = try build(
|
||||
optionKeys: ["EPIJ_CMat"])
|
||||
#expect(withBypass.contains("EPIJ_CMat=3"))
|
||||
|
||||
let captured = try build(
|
||||
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
||||
optionKeys: ["EPIJ_CMat"])
|
||||
// Captured value kept, detection not re-applied.
|
||||
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
|
||||
== ["EPIJ_CMat=1"])
|
||||
}
|
||||
|
||||
@Test("Orientation: portrait=3 landscape=4; captured wins")
|
||||
func orientation() throws {
|
||||
#expect(try build(options: PrintOptions(orientation: "portrait"))
|
||||
.contains("orientation-requested=3"))
|
||||
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
||||
.contains("orientation-requested=4"))
|
||||
let capturedOrients = try build(options: PrintOptions(
|
||||
orientation: "landscape",
|
||||
cupsOptions: "orientation-requested=5"))
|
||||
#expect(!capturedOrients.contains("orientation-requested=4"))
|
||||
#expect(capturedOrients.contains("orientation-requested=5"))
|
||||
}
|
||||
|
||||
@Test("PageSize emitted unless captured")
|
||||
func pageSize() throws {
|
||||
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
||||
.contains("PageSize=A4"))
|
||||
let capturedSize = try build(options: PrintOptions(
|
||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||
#expect(!capturedSize.contains("PageSize=A4"))
|
||||
#expect(capturedSize.contains("PageSize=Letter"))
|
||||
}
|
||||
|
||||
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
||||
func sanitise() throws {
|
||||
#expect(throws: LpArgsError.self) {
|
||||
_ = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear;rm -rf /"))
|
||||
}
|
||||
#expect(throws: LpArgsError.self) {
|
||||
_ = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
|
||||
}
|
||||
#expect(throws: LpArgsError.self) {
|
||||
_ = try build(options: PrintOptions(
|
||||
cupsOptions: "InputSlot=$(whoami)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("ProfilingPreset")
|
||||
struct ProfilingPresetTests {
|
||||
|
||||
@Test("snake_case keys round-trip through Codable")
|
||||
func roundTrip() throws {
|
||||
var p = PresetCatalog.highQualityCMYK
|
||||
p.colprofInputViewingCond = "D50_2"
|
||||
let data = try JSONEncoder().encode(p)
|
||||
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||
#expect(decoded == p)
|
||||
// Spot-check the wire format.
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
#expect(obj["colour_space"] as? String == "cmyk")
|
||||
#expect(obj["patch_count"] as? Int == 1500)
|
||||
#expect(obj["total_ink_limit"] as? Int == 320)
|
||||
#expect(obj["bit_depth"] as? Int == 16)
|
||||
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
|
||||
}
|
||||
|
||||
@Test("Unknown keys ignored; missing required field fails")
|
||||
func schemaTolerance() throws {
|
||||
let json = """
|
||||
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
|
||||
"white_patches":1,"black_patches":1,"instrument":"i1",
|
||||
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
|
||||
""".data(using: .utf8)!
|
||||
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
|
||||
#expect(ok.id == "x")
|
||||
|
||||
let missing = """
|
||||
{"id":"x","name":"N","colour_space":"rgb"}
|
||||
""".data(using: .utf8)!
|
||||
#expect(throws: DecodingError.self) {
|
||||
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Validation rejects bad colour space / dpi / bit depth")
|
||||
func validation() {
|
||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
||||
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
|
||||
}
|
||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
||||
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
|
||||
}
|
||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
||||
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
|
||||
}
|
||||
#expect(throws: ProfilingPreset.ValidationError.self) {
|
||||
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PresetCatalog")
|
||||
struct PresetCatalogTests {
|
||||
|
||||
@Test("Four built-ins with the documented values")
|
||||
func builtIns() {
|
||||
#expect(PresetCatalog.builtIns.count == 4)
|
||||
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
|
||||
|
||||
let std = byID["preset-std-rgb"]!
|
||||
#expect(std.colourSpace == "rgb" && std.patchCount == 800
|
||||
&& std.pageSize == "A4" && std.bitDepth == 8
|
||||
&& std.dpi == 300 && std.colprofQuality == "m"
|
||||
&& std.whitePatches == 4 && std.blackPatches == 4)
|
||||
|
||||
let hq = byID["preset-hq-cmyk"]!
|
||||
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
|
||||
&& hq.pageSize == "A3" && hq.bitDepth == 16
|
||||
&& hq.dpi == 300 && hq.colprofQuality == "h"
|
||||
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
|
||||
|
||||
let draft = byID["preset-draft-rgb"]!
|
||||
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
|
||||
&& draft.pageSize == "A4" && draft.bitDepth == 8
|
||||
&& draft.dpi == 150 && draft.colprofQuality == "l")
|
||||
|
||||
let ultra = byID["preset-ultra-rgb"]!
|
||||
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
|
||||
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
|
||||
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
|
||||
&& ultra.ofpsHighQuality == true
|
||||
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
|
||||
|
||||
for p in PresetCatalog.builtIns {
|
||||
#expect(p.instrument == "i1")
|
||||
#expect(p.colprofFwa == "D50")
|
||||
#expect(p.randomSeed == 1)
|
||||
#expect(p.noRandomize == false)
|
||||
#expect(p.colprofAlgorithm == "l")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Custom presets overlay by id; built-ins are not deletable")
|
||||
func overlay() {
|
||||
let custom = ProfilingPreset(
|
||||
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
|
||||
let all = PresetCatalog.all(custom: [custom])
|
||||
#expect(all.count == 4)
|
||||
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
|
||||
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
|
||||
#expect(!PresetCatalog.isBuiltIn("custom-1"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PresetStore")
|
||||
struct PresetStoreTests {
|
||||
|
||||
private func tempSettingsURL() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Test("CRUD + export/import round-trip")
|
||||
func crud() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||
|
||||
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
|
||||
try store.saveCustom(p)
|
||||
#expect(store.customs().count == 1)
|
||||
#expect(store.all().count == 5)
|
||||
|
||||
p.name = "Renamed"
|
||||
try store.saveCustom(p)
|
||||
#expect(store.customs().count == 1)
|
||||
#expect(store.customs()[0].name == "Renamed")
|
||||
|
||||
let data = try store.export(p)
|
||||
let imported = try store.import(data)
|
||||
#expect(imported.name == "Renamed")
|
||||
#expect(imported.dpi == 150)
|
||||
|
||||
#expect(try store.deleteCustom(id: "custom-x"))
|
||||
#expect(store.customs().isEmpty)
|
||||
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
|
||||
}
|
||||
|
||||
@Test("Import rewrites a built-in id to a fresh custom id")
|
||||
func importBuiltinCollision() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||
let data = try store.export(PresetCatalog.standardRGB)
|
||||
let imported = try store.import(data)
|
||||
#expect(imported.id.hasPrefix("custom-"))
|
||||
#expect(!PresetCatalog.isBuiltIn(imported.id))
|
||||
}
|
||||
|
||||
@Test("Built-ins are immutable through saveCustom")
|
||||
func builtInImmutable() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||
var shadowed = PresetCatalog.standardRGB
|
||||
shadowed.name = "Hacked"
|
||||
#expect(throws: PresetStore.PresetStoreError.self) {
|
||||
try store.saveCustom(shadowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AppSettings preset migration")
|
||||
struct PresetMigrationTests {
|
||||
|
||||
private func tempSettingsURL() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Test("Legacy M1 custom_presets migrate to typed schema")
|
||||
func legacyMigration() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let legacy = """
|
||||
{"custom_presets":[
|
||||
{"name":"Old One","values":{"colour_space":"cmyk","patch_count":"900",
|
||||
"dpi":"150","bit_depth":"16","instrument":"p3","page_size":"A3"}},
|
||||
{"name":"","values":{}},
|
||||
42
|
||||
]}
|
||||
""".data(using: .utf8)!
|
||||
try legacy.write(to: url)
|
||||
|
||||
let settings = SettingsStore(fileURL: url).load()
|
||||
#expect(settings.customPresets.count == 1)
|
||||
let p = settings.customPresets[0]
|
||||
#expect(p.name == "Old One")
|
||||
#expect(p.id.hasPrefix("custom-0-"))
|
||||
#expect(p.colourSpace == "cmyk")
|
||||
#expect(p.patchCount == 900)
|
||||
#expect(p.dpi == 150)
|
||||
#expect(p.bitDepth == 16)
|
||||
#expect(p.instrument == "p3")
|
||||
#expect(p.pageSize == "A3")
|
||||
}
|
||||
|
||||
@Test("Typed presets load and re-save as the typed schema")
|
||||
func typedRoundTrip() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings()
|
||||
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
|
||||
try store.save(s)
|
||||
let loaded = store.load()
|
||||
#expect(loaded.customPresets.first?.patchCount == 700)
|
||||
}
|
||||
|
||||
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
|
||||
func draftDPI() throws {
|
||||
let url = try tempSettingsURL()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
|
||||
let data = try store.export(PresetCatalog.draftRGB)
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
#expect(obj["dpi"] as? Int == 150)
|
||||
let back = try store.import(data)
|
||||
#expect(back.dpi == 150)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
||||
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
||||
/// `UITestHooks` seam the UI tests rely on.
|
||||
@Suite("PrintPanelStub")
|
||||
struct PrintPanelStubTests {
|
||||
|
||||
private func withEnv(
|
||||
_ vars: [String: String?],
|
||||
_ body: () throws -> Void
|
||||
) rethrows {
|
||||
var saved: [String: String?] = [:]
|
||||
for key in vars.keys {
|
||||
saved[key] = ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
for (key, value) in vars {
|
||||
if let value { setenv(key, value, 1) } else { unsetenv(key) }
|
||||
}
|
||||
defer {
|
||||
for (key, value) in saved {
|
||||
if let value { setenv(key, value, 1) } else { unsetenv(key) }
|
||||
}
|
||||
}
|
||||
try body()
|
||||
}
|
||||
|
||||
@Test("Cancel returns nil — not an error")
|
||||
func cancelIsNil() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
||||
]) {
|
||||
#expect(UITestHooks.printPanelStubbed)
|
||||
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK returns captured options + selected printer")
|
||||
func okResult() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
|
||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
#expect(result?.selectedPrinter == "Other_Queue")
|
||||
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
|
||||
#expect(result?.options.mediaType == "Photo")
|
||||
#expect(result?.options.ppdUncorrectedPassthrough == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK defaults selected printer to the opened queue")
|
||||
func okDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS": nil,
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
||||
#expect(result?.selectedPrinter == "My_Queue")
|
||||
#expect(result?.options.cupsOptions == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("PrinttargArgs")
|
||||
struct PrinttargArgsTests {
|
||||
|
||||
private func config(
|
||||
instrument: PrintInstrument = .i1,
|
||||
pageSize: PageSize = .a4,
|
||||
customW: Double = 210, customH: Double = 297,
|
||||
bitDepth: TiffBitDepth = .eight,
|
||||
dpi: Int = 300,
|
||||
layout: LayoutOrder = .deterministic,
|
||||
seed: Int = 1,
|
||||
label: String? = nil,
|
||||
calFile: String? = nil,
|
||||
calEmbed: Bool = false,
|
||||
basename: String = "target"
|
||||
) -> PrinttargConfig {
|
||||
PrinttargConfig(
|
||||
instrument: instrument, pageSize: pageSize,
|
||||
customPageWidth: customW, customPageHeight: customH,
|
||||
bitDepth: bitDepth, dpi: dpi,
|
||||
layoutOrder: layout, customSeed: seed, label: label,
|
||||
calibrationFile: calFile, calibrationEmbedOnly: calEmbed,
|
||||
basename: basename
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
|
||||
func baseline() throws {
|
||||
let args = try PrinttargArgs.build(config: config())
|
||||
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
|
||||
"-R", "1", "-t", "300", "target"])
|
||||
}
|
||||
|
||||
@Test("Default layout is deterministic -R 1, never bare")
|
||||
func deterministicDefault() throws {
|
||||
let args = try PrinttargArgs.build(config: config())
|
||||
#expect(args.contains("-R"))
|
||||
#expect(!args.contains("-r"))
|
||||
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
|
||||
}
|
||||
|
||||
@Test("Custom seed -R N; seed < 1 throws")
|
||||
func customSeed() throws {
|
||||
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
|
||||
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
|
||||
func raster() throws {
|
||||
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
|
||||
#expect(args.contains("-r"))
|
||||
#expect(!args.contains("-R"))
|
||||
}
|
||||
|
||||
@Test("Label: -d emits the resolved string, not a colour space")
|
||||
func label() throws {
|
||||
let args = try PrinttargArgs.build(
|
||||
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
|
||||
let i = args.firstIndex(of: "-d")!
|
||||
#expect(args[i + 1].hasPrefix("ICCery - t"))
|
||||
}
|
||||
|
||||
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600")
|
||||
func bitDepthAndDPI() throws {
|
||||
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
|
||||
.contains("-T"))
|
||||
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
|
||||
.contains("-t"))
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(dpi: 71))
|
||||
}
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(dpi: 601))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("All instruments emit their Argyll code")
|
||||
func instruments() throws {
|
||||
let expected: [(PrintInstrument, String)] = [
|
||||
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
|
||||
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
|
||||
]
|
||||
for (inst, code) in expected {
|
||||
let args = try PrinttargArgs.build(config: config(instrument: inst))
|
||||
#expect(args[args.firstIndex(of: "-i")! + 1] == code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("All fixed page sizes; custom emits WxH in mm")
|
||||
func pageSizes() throws {
|
||||
for size in PageSize.allCases where size != .custom {
|
||||
let args = try PrinttargArgs.build(config: config(pageSize: size))
|
||||
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
|
||||
}
|
||||
let custom = try PrinttargArgs.build(config: config(
|
||||
pageSize: .custom, customW: 150, customH: 220))
|
||||
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
|
||||
}
|
||||
|
||||
@Test("Custom page below 50 mm throws")
|
||||
func customPageTooSmall() {
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
|
||||
}
|
||||
#expect(throws: PrinttargArgError.self) {
|
||||
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Calibration: -K applies, -I embeds")
|
||||
func calibrationFlags() throws {
|
||||
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
|
||||
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
|
||||
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
|
||||
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
|
||||
#expect(!i.contains("-K"))
|
||||
}
|
||||
|
||||
@Test("CAL_ basename never gets -K or -I")
|
||||
func calProtection() throws {
|
||||
let args = try PrinttargArgs.build(
|
||||
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
|
||||
#expect(!args.contains("-K"))
|
||||
#expect(!args.contains("-I"))
|
||||
}
|
||||
|
||||
@Test("Unsafe basename throws")
|
||||
func unsafeBasename() {
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try PrinttargArgs.build(config: config(basename: "../x"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PrinttargLabel")
|
||||
struct PrinttargLabelTests {
|
||||
|
||||
private var fixedDate: Date {
|
||||
var comps = DateComponents()
|
||||
comps.year = 2026; comps.month = 2; comps.day = 3
|
||||
comps.hour = 14; comps.minute = 5
|
||||
return Calendar(identifier: .gregorian).date(from: comps)!
|
||||
}
|
||||
|
||||
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
|
||||
func automatic() {
|
||||
let label = PrinttargLabel.automatic(
|
||||
basename: "tgt",
|
||||
metadata: TargetLabelMetadata(
|
||||
printer: "Epson", inkSet: "CMYK",
|
||||
driverPaper: "Photo", actualPaper: "Matte"),
|
||||
date: fixedDate, timeZone: .current)
|
||||
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
|
||||
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
|
||||
}
|
||||
|
||||
@Test("Missing metadata becomes Unspecified")
|
||||
func unspecified() {
|
||||
let label = PrinttargLabel.automatic(
|
||||
basename: "tgt", metadata: TargetLabelMetadata(),
|
||||
date: fixedDate, timeZone: .current)
|
||||
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
|
||||
}
|
||||
|
||||
@Test("Manual label wins over automatic")
|
||||
func manualWins() {
|
||||
let resolved = PrinttargLabel.resolved(
|
||||
customLabel: " My Label ", basename: "tgt",
|
||||
metadata: TargetLabelMetadata(), date: fixedDate)
|
||||
#expect(resolved == "My Label")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("PrinttargManifest")
|
||||
struct PrinttargManifestTests {
|
||||
|
||||
private let prettySingle = """
|
||||
Some log line
|
||||
Doing work...
|
||||
{
|
||||
"event": "manifest",
|
||||
"pages": [
|
||||
{
|
||||
"filename": "target.tif",
|
||||
"patches": 800,
|
||||
"width_mm": 210.0,
|
||||
"height_mm": 297.0
|
||||
}
|
||||
]
|
||||
}
|
||||
trailing text
|
||||
"""
|
||||
|
||||
private let prettyMulti = """
|
||||
{
|
||||
"event": "manifest",
|
||||
"pages": [
|
||||
{"filename": "p1.tif", "patches": 400, "width_mm": 210, "height_mm": 148},
|
||||
{"filename": "p2.tif", "patches": 400, "width_mm": 210, "height_mm": 148}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("Decodes a single-page pretty manifest amid log noise")
|
||||
func singlePage() throws {
|
||||
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
|
||||
#expect(m.event == "manifest")
|
||||
#expect(m.pages.count == 1)
|
||||
#expect(m.pages[0].filename == "target.tif")
|
||||
#expect(m.pages[0].patches == 800)
|
||||
}
|
||||
|
||||
@Test("Multi-page manifest preserves order")
|
||||
func multiPage() throws {
|
||||
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
|
||||
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
|
||||
}
|
||||
|
||||
@Test("No JSON document → noJSONDocument")
|
||||
func noJSON() {
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Wrong event → wrongEvent")
|
||||
func wrongEvent() {
|
||||
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ROW_COLORS_JSON line is never treated as the manifest")
|
||||
func rowColorsNotManifest() {
|
||||
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
|
||||
// Extraction only starts at a '{' that begins a trimmed line,
|
||||
// so the ROW_COLORS_JSON line is skipped entirely.
|
||||
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
|
||||
#expect(m != nil)
|
||||
#expect(m?.event == "manifest")
|
||||
}
|
||||
|
||||
@Test("Braces inside a quoted filename do not corrupt the scan")
|
||||
func bracesInFilename() throws {
|
||||
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
|
||||
let m = try PrinttargManifestExtractor.manifest(from: stdout)
|
||||
#expect(m.pages[0].filename == "a}b.tif")
|
||||
}
|
||||
|
||||
@Test("Unsafe / non-TIFF filenames rejected")
|
||||
func unsafeFilenames() {
|
||||
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
|
||||
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
|
||||
#expect(throws: ManifestError.self) {
|
||||
try PrinttargManifestExtractor.manifest(from: stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArgyllRunner Printtarg")
|
||||
struct ArgyllRunnerPrinttargTests {
|
||||
|
||||
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let url = dir.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
return dir
|
||||
}
|
||||
|
||||
/// A minimal valid TIFF (8-bit, tiny) for gallery preview tests.
|
||||
private func writeTinyTIFF(at url: URL) throws {
|
||||
// 1x1 8-bit grayscale TIFF, little-endian.
|
||||
var bytes: [UInt8] = [
|
||||
0x49, 0x49, 0x2A, 0x00, // II + magic
|
||||
0x08, 0x00, 0x00, 0x00, // IFD offset
|
||||
]
|
||||
let ifdCount: UInt16 = 10
|
||||
bytes += withUnsafeBytes(of: ifdCount.littleEndian) { Array($0) }
|
||||
func tag(_ t: UInt16, _ type: UInt16, _ count: UInt32, _ value: UInt32) {
|
||||
bytes += withUnsafeBytes(of: t.littleEndian) { Array($0) }
|
||||
bytes += withUnsafeBytes(of: type.littleEndian) { Array($0) }
|
||||
bytes += withUnsafeBytes(of: count.littleEndian) { Array($0) }
|
||||
bytes += withUnsafeBytes(of: value.littleEndian) { Array($0) }
|
||||
}
|
||||
tag(256, 3, 1, 1) // ImageWidth = 1
|
||||
tag(257, 3, 1, 1) // ImageLength = 1
|
||||
tag(258, 3, 1, 8) // BitsPerSample = 8
|
||||
tag(259, 3, 1, 1) // Compression = none
|
||||
tag(262, 3, 1, 1) // Photometric = BlackIsZero
|
||||
tag(273, 4, 1, 0) // StripOffsets — patched below
|
||||
tag(277, 3, 1, 1) // SamplesPerPixel = 1
|
||||
tag(278, 3, 1, 1) // RowsPerStrip = 1
|
||||
tag(279, 4, 1, 1) // StripByteCounts = 1
|
||||
tag(284, 3, 1, 1) // PlanarConfig
|
||||
bytes += [0, 0, 0, 0] // next IFD = none
|
||||
let pixelOffset = bytes.count
|
||||
bytes += [0x80] // the pixel
|
||||
// Patch StripOffsets (located right after the tag header at
|
||||
// offset 8 + 2 + 5*12 + 8 = position of value field).
|
||||
let valuePos = 8 + 2 + 5 * 12 + 8
|
||||
let off = UInt32(pixelOffset).littleEndian
|
||||
withUnsafeBytes(of: off) { b in
|
||||
bytes[valuePos] = b[0]; bytes[valuePos+1] = b[1]
|
||||
bytes[valuePos+2] = b[2]; bytes[valuePos+3] = b[3]
|
||||
}
|
||||
try Data(bytes).write(to: url)
|
||||
}
|
||||
|
||||
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
|
||||
func success() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
printf 'log line\\n'
|
||||
printf '{\\n "event": "manifest",\\n "pages": [\\n {"filename": "%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\\n ]\\n}\\n' "$last"
|
||||
touch "$last.ti2"
|
||||
exit 0
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
// Basename "pt" → manifest references pt.tif; write a real TIFF.
|
||||
try writeTinyTIFF(at: dir.appendingPathComponent("pt.tif"))
|
||||
|
||||
let resolver = BinaryResolver(bundledRoot: dir, overrideDir: dir)
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(), binaryResolver: resolver)
|
||||
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
|
||||
let result = try await runner.runPrinttarg(config: config)
|
||||
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
|
||||
#expect(result.manifest.pages.count == 1)
|
||||
#expect(result.pages.count == 1)
|
||||
let png = result.pages[0].previewPNG
|
||||
#expect(png != nil)
|
||||
if let png {
|
||||
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Non-zero exit throws processFailed and stays on stage")
|
||||
func failure() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
echo "oops" >&2
|
||||
exit 3
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Exit 0 without manifest → malformedManifest")
|
||||
func noManifest() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
touch "$last.ti2"
|
||||
echo "no json here"
|
||||
exit 0
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Exit 0 without .ti2 → missingArtefact")
|
||||
func noTi2() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
||||
exit 0
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "x", workingDirectory: dir))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Deterministic config produces byte-identical .ti2")
|
||||
func determinism() async throws {
|
||||
let dir = try makeFixture("""
|
||||
#!/bin/sh
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
printf 'TI2\\nDETERMINISTIC\\n' > "$last.ti2"
|
||||
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
|
||||
exit 0
|
||||
""")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let runner = ArgyllRunner(
|
||||
processManager: ProcessManager(),
|
||||
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
|
||||
// Two runs, two basenames — same argv except basename.
|
||||
_ = try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "a", workingDirectory: dir))
|
||||
_ = try await runner.runPrinttarg(
|
||||
config: PrinttargConfig(basename: "b", workingDirectory: dir))
|
||||
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
|
||||
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
|
||||
#expect(d1 == d2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func tempStoreURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-settings-\(UUID().uuidString)")
|
||||
.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
@Suite("AppSettings")
|
||||
struct AppSettingsTests {
|
||||
@Test func defaults() {
|
||||
let s = AppSettings.default
|
||||
#expect(s.argyllBinaryDir == nil)
|
||||
#expect(s.defaultInstrument == nil)
|
||||
#expect(s.logLevel == nil)
|
||||
#expect(s.deltaEGoodMax == 2.0)
|
||||
#expect(s.deltaEWarningMax == 5.0)
|
||||
#expect(s.customPresets.isEmpty)
|
||||
#expect(!s.enableI1Pro2Leds)
|
||||
#expect(s.calibrationStaleDays == 30)
|
||||
#expect(s.defaultInstallLocation == .user)
|
||||
#expect(s.askBeforeOverwriteProfile)
|
||||
#expect(!s.openColorPanelAfterInstall)
|
||||
#expect(s.isValid)
|
||||
}
|
||||
|
||||
@Test func negativeThresholds() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = -1
|
||||
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
|
||||
s.deltaEGoodMax = 2.0
|
||||
s.deltaEWarningMax = -0.5
|
||||
// -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too
|
||||
#expect(s.validate() == [
|
||||
AppSettings.errorNegativeDeltaE,
|
||||
AppSettings.errorThresholdOrder,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func goodMustBeStrictlyLessThanWarning() {
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 5.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 6.0
|
||||
#expect(s.validate() == [AppSettings.errorThresholdOrder])
|
||||
s.deltaEGoodMax = 4.9
|
||||
#expect(s.isValid)
|
||||
}
|
||||
|
||||
@Test func snakeCaseKeys() throws {
|
||||
let s = AppSettings.default
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let json = String(data: data, encoding: .utf8)!
|
||||
#expect(json.contains("\"delta_e_good_max\""))
|
||||
#expect(json.contains("\"default_install_location\""))
|
||||
#expect(json.contains("\"enable_i1pro2_leds\""))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("SettingsStore")
|
||||
struct SettingsStoreTests {
|
||||
@Test func roundTrip() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 1.5
|
||||
s.defaultInstrument = "p3"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
}
|
||||
|
||||
@Test func corruptJsonFallsBackToDefaults() throws {
|
||||
let url = tempStoreURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(SettingsStore(fileURL: url).load() == .default)
|
||||
}
|
||||
|
||||
@Test func missingFileReturnsDefaults() {
|
||||
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
|
||||
}
|
||||
|
||||
@Test func invalidSettingsNotPersisted() throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var s = AppSettings.default
|
||||
s.deltaEGoodMax = 9.0 // >= warning 5.0
|
||||
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
@Test func savePostsNotification() async throws {
|
||||
let url = tempStoreURL()
|
||||
let store = SettingsStore(fileURL: url)
|
||||
var fired = false
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||
) { _ in fired = true }
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
try store.save(.default)
|
||||
#expect(fired)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("LogSink")
|
||||
struct LogSinkTests {
|
||||
private func tempLog() -> (URL, LogSink) {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
|
||||
.appendingPathComponent("iccery.log")
|
||||
return (url, LogSink(fileURL: url))
|
||||
}
|
||||
|
||||
@Test func writesFormattedLines() {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "test", message: "hello")
|
||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
#expect(content.contains("[INFO] test: hello"))
|
||||
}
|
||||
|
||||
@Test func levelFilteringIsLive() {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.error)
|
||||
sink.write(level: .info, category: "t", message: "hidden")
|
||||
sink.setLevel(.info) // runtime change, no restart (#158)
|
||||
sink.write(level: .info, category: "t", message: "shown")
|
||||
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
#expect(!content.contains("hidden"))
|
||||
#expect(content.contains("shown"))
|
||||
}
|
||||
|
||||
@Test func rotatesAt5MiBKeeping5Segments() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.trace)
|
||||
// Pre-fill the active log just under the cap, then cross it.
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes))
|
||||
try big.write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
sink.write(level: .info, category: "t", message: "trigger rotation")
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: url.appendingPathExtension("1").path
|
||||
))
|
||||
// Active log is small again.
|
||||
let size = try FileManager.default.attributesOfItem(
|
||||
atPath: url.path
|
||||
)[.size] as? UInt64
|
||||
#expect((size ?? 0) < 1024)
|
||||
}
|
||||
|
||||
@Test func tailExcerptCaps() throws {
|
||||
let (url, sink) = tempLog()
|
||||
sink.setLevel(.debug)
|
||||
sink.write(level: .info, category: "t", message: "line")
|
||||
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("TargenArgs")
|
||||
struct TargenArgsTests {
|
||||
|
||||
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
|
||||
func rgbBaseline() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "test_rgb"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
|
||||
#expect(!args.contains("-u"))
|
||||
}
|
||||
|
||||
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
|
||||
func cmykBaseline() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 1500,
|
||||
whitePatches: 4,
|
||||
blackPatches: 0,
|
||||
basename: "test_cmyk"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
|
||||
}
|
||||
|
||||
@Test("Custom patch count honours -f (#44)")
|
||||
func customPatchCount() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 2500,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "custom_patches"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args.contains("-f"))
|
||||
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
|
||||
}
|
||||
|
||||
@Test("All advanced flags in stable order")
|
||||
func allAdvancedFlags() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 1200,
|
||||
whitePatches: 6,
|
||||
blackPatches: 2,
|
||||
greySteps: 12,
|
||||
singleChannelSteps: 8,
|
||||
neutralSteps: 6,
|
||||
neutralConcentration: 0.75,
|
||||
preconditioningProfile: "/path/to/profile.icc",
|
||||
ofpsHighQuality: true,
|
||||
ofpsAdaptation: 0.10,
|
||||
fullSpreadAlgorithm: .target,
|
||||
totalInkLimit: 320,
|
||||
darkEmphasis: 1.50,
|
||||
devicePower: 2.0,
|
||||
basename: "advanced_cmyk"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
let expected = [
|
||||
"-v", "-d", "4",
|
||||
"-f", "1200",
|
||||
"-e", "6",
|
||||
"-B", "2",
|
||||
"-g", "12",
|
||||
"-s", "8",
|
||||
"-n", "6",
|
||||
"-N", "0.75",
|
||||
"-c", "/path/to/profile.icc",
|
||||
"-G",
|
||||
"-A", "0.10",
|
||||
"-t",
|
||||
"-l", "320",
|
||||
"-V", "1.50",
|
||||
"-p", "2.00",
|
||||
"advanced_cmyk"
|
||||
]
|
||||
#expect(args == expected)
|
||||
}
|
||||
|
||||
@Test("RGB ignores total ink limit")
|
||||
func rgbIgnoresInkLimit() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
totalInkLimit: 300,
|
||||
basename: "rgb_no_ink"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-l"))
|
||||
}
|
||||
|
||||
@Test("Neutral concentration omitted when approximately 0.50")
|
||||
func neutralConcentrationOmittedWhenDefault() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
neutralConcentration: 0.5005,
|
||||
basename: "n_default"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-N"))
|
||||
}
|
||||
|
||||
@Test("Adaptation emitted even at 0.10 (no default-skip)")
|
||||
func adaptationEmittedAtPointOne() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
ofpsAdaptation: 0.10,
|
||||
basename: "a_flag"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(args.contains("-A"))
|
||||
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
|
||||
}
|
||||
|
||||
@Test("OFPS full spread algorithm emits no flag")
|
||||
func ofpsEmitsNoFlag() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
fullSpreadAlgorithm: .ofps,
|
||||
basename: "ofps_test"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("ofps"))
|
||||
#expect(!args.contains("-t"))
|
||||
}
|
||||
|
||||
@Test("Dark emphasis and device power omitted when 1.0")
|
||||
func darkEmphasisAndPowerOmittedWhenOne() throws {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
darkEmphasis: 1.0,
|
||||
devicePower: 1.0,
|
||||
basename: "defaults_omitted"
|
||||
)
|
||||
let args = try TargenArgs.build(config: config)
|
||||
#expect(!args.contains("-V"))
|
||||
#expect(!args.contains("-p"))
|
||||
}
|
||||
|
||||
@Test("Invalid basename throws")
|
||||
func invalidBasenameThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "../bad_name"
|
||||
)
|
||||
#expect(throws: PathSecurity.Error.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invalid patch count throws")
|
||||
func invalidPatchCountThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 0,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "bad_count"
|
||||
)
|
||||
#expect(throws: TargenArgError.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Invalid ink limit throws for CMYK")
|
||||
func invalidInkLimitThrows() {
|
||||
let config = TargenConfig(
|
||||
colourSpace: .cmyk,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 0,
|
||||
totalInkLimit: 450,
|
||||
basename: "bad_ink"
|
||||
)
|
||||
#expect(throws: TargenArgError.self) {
|
||||
try TargenArgs.build(config: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ArgyllRunner Targen")
|
||||
struct ArgyllRunnerTargenTests {
|
||||
|
||||
@Test("Successful targen execution creates .ti1 and returns URL")
|
||||
func successfulTargenExecution() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
// Create a mock targen script
|
||||
let mockScript = """
|
||||
#!/bin/sh
|
||||
# Find the last argument which is the basename
|
||||
for arg do shift; set -- "$@" "$arg"; done
|
||||
last="$arg"
|
||||
echo "Generating patches..."
|
||||
touch "$last.ti1"
|
||||
echo "Done!"
|
||||
exit 0
|
||||
"""
|
||||
let mockURL = tempDir.appendingPathComponent("targen")
|
||||
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||
|
||||
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
|
||||
let pm = ProcessManager()
|
||||
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
|
||||
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "mock_test",
|
||||
workingDirectory: tempDir
|
||||
)
|
||||
|
||||
var logLines: [String] = []
|
||||
final class LogBox: @unchecked Sendable {
|
||||
var lines: [String] = []
|
||||
let lock = NSLock()
|
||||
func append(_ batch: [String]) {
|
||||
lock.lock(); lines.append(contentsOf: batch); lock.unlock()
|
||||
}
|
||||
}
|
||||
let box = LogBox()
|
||||
let ti1URL = try await runner.runTargen(config: config) { batch in
|
||||
box.append(batch)
|
||||
}
|
||||
logLines = box.lines
|
||||
#expect(logLines.contains("Generating patches..."))
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
|
||||
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
|
||||
}
|
||||
|
||||
@Test("Failed targen execution throws processFailed")
|
||||
func failedTargenExecution() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let mockScript = """
|
||||
#!/bin/sh
|
||||
echo "Error: something went wrong" >&2
|
||||
exit 1
|
||||
"""
|
||||
let mockURL = tempDir.appendingPathComponent("targen")
|
||||
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||
|
||||
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
|
||||
let pm = ProcessManager()
|
||||
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
|
||||
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "fail_test",
|
||||
workingDirectory: tempDir
|
||||
)
|
||||
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
try await runner.runTargen(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Targen exit 0 without .ti1 throws missingArtefact")
|
||||
func missingArtefactThrows() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let mockScript = """
|
||||
#!/bin/sh
|
||||
echo "Exited 0 but did not create file"
|
||||
exit 0
|
||||
"""
|
||||
let mockURL = tempDir.appendingPathComponent("targen")
|
||||
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
|
||||
|
||||
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
|
||||
let pm = ProcessManager()
|
||||
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
|
||||
|
||||
let config = TargenConfig(
|
||||
colourSpace: .rgb,
|
||||
patchCount: 800,
|
||||
whitePatches: 4,
|
||||
blackPatches: 4,
|
||||
basename: "no_file",
|
||||
workingDirectory: tempDir
|
||||
)
|
||||
|
||||
await #expect(throws: ArgyllRunnerError.self) {
|
||||
try await runner.runTargen(config: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
private func artefacts(
|
||||
ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false
|
||||
) -> StageArtefacts {
|
||||
var a = StageArtefacts()
|
||||
a.stage1Complete = ti1
|
||||
a.stage2Complete = ti2
|
||||
a.stage3Complete = ti3
|
||||
a.stage4Complete = profile
|
||||
if profile {
|
||||
a.profilePath = URL(fileURLWithPath: "/x/t.icc")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@Suite("WizardGating matrix")
|
||||
struct WizardGatingTests {
|
||||
|
||||
@Test func emptyProjectOnlyStage1() {
|
||||
let a = artefacts()
|
||||
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
|
||||
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
|
||||
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func ti1UnlocksStage2Only() {
|
||||
let a = artefacts(ti1: true)
|
||||
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func stage3NeedsTi1AndTi2() {
|
||||
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
|
||||
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
|
||||
}
|
||||
|
||||
@Test func stage4NeedsTi3NotTi2() {
|
||||
// #109/#110: .ti2 alone must never unlock Stage 4.
|
||||
let a = artefacts(ti1: true, ti2: true)
|
||||
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
|
||||
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
|
||||
}
|
||||
|
||||
@Test func stage5NeedsTi3AndProfile() {
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
|
||||
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
|
||||
#expect(WizardGating.isUnlocked(
|
||||
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func forwardGatedBackwardFree() {
|
||||
let a = artefacts()
|
||||
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
|
||||
// Backward always allowed even when artefacts vanished.
|
||||
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
|
||||
// Same stage is a no-op.
|
||||
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
|
||||
// Stage 0 is a side-trip, never gated.
|
||||
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
|
||||
}
|
||||
|
||||
@Test func deepestUnlocked() {
|
||||
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti1: true, ti2: true)
|
||||
) == .measure)
|
||||
#expect(WizardGating.deepestUnlocked(
|
||||
artefacts: artefacts(ti3: true, profile: true)
|
||||
) == .verifyInstall)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStateStore")
|
||||
struct WizardStateStoreTests {
|
||||
private func tempURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
}
|
||||
|
||||
@Test func roundTrip() throws {
|
||||
let url = tempURL()
|
||||
let store = WizardStateStore(fileURL: url)
|
||||
var s = WizardState()
|
||||
s.currentStage = 3
|
||||
s.basename = "run-42"
|
||||
s.cwd = "/tmp/charts"
|
||||
s.sessionMode = .calibration
|
||||
s.profileBasename = "imported"
|
||||
try store.save(s)
|
||||
#expect(store.load() == s)
|
||||
}
|
||||
|
||||
@Test func missingFileDefaults() {
|
||||
let s = WizardStateStore(fileURL: tempURL()).load()
|
||||
#expect(s == .default)
|
||||
#expect(s.stage == .generate)
|
||||
#expect(s.sessionMode == .profile)
|
||||
}
|
||||
|
||||
@Test func corruptStageFallsBackToGenerate() throws {
|
||||
let url = tempURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
|
||||
.write(to: url, atomically: true, encoding: .utf8)
|
||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||
}
|
||||
|
||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||
var s = WizardState(sessionMode: .calibration)
|
||||
let data = try JSONEncoder().encode(s)
|
||||
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
|
||||
#expect(decoded.sessionMode == .calibration)
|
||||
s.sessionMode = .profile
|
||||
#expect(s.sessionMode == .profile)
|
||||
}
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Mock lp for Milestone3UITests. Appends its full argv to
|
||||
# ICCERY_TEST_LP_ARGV so the test can assert flag order and option
|
||||
# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection).
|
||||
{
|
||||
printf 'lp'
|
||||
for arg in "$@"; do printf ' %s' "$arg"; done
|
||||
printf '\n'
|
||||
} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}"
|
||||
if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then
|
||||
echo "mock lp failure" >&2
|
||||
exit "$ICCERY_MOCK_LP_EXIT"
|
||||
fi
|
||||
exit 0
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
||||
queue=""
|
||||
list=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-p) shift_flag=1 ;;
|
||||
-l) list=1 ;;
|
||||
-*) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
|
||||
exit 0
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# Mock lpstat for Milestone3UITests. Emits two canned queues so the UI
|
||||
# can exercise select/refresh/status-badge without real CUPS.
|
||||
case "$1" in
|
||||
-e)
|
||||
printf 'Mock_Epson_7450\nMock_Canon_Pro\n'
|
||||
;;
|
||||
-p)
|
||||
printf 'printer Mock_Epson_7450 is idle. enabled since Mon Sep 7 21:50:25 2026\n'
|
||||
printf 'printer Mock_Canon_Pro disabled since Tue Sep 8 09:00:00 2026 -\n\tPaused\n'
|
||||
;;
|
||||
-d)
|
||||
printf 'system default destination: Mock_Epson_7450\n'
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty
|
||||
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
|
||||
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then
|
||||
echo "mock printtarg failure" >&2
|
||||
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
|
||||
fi
|
||||
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif"
|
||||
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n'
|
||||
touch "$last.ti2"
|
||||
exit 0
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Mock targen for Milestone2UITests. Emits a fake .ti1 next to the
|
||||
# basename (last argv) in the process working directory. Exit code is
|
||||
# overridable via ICCERY_MOCK_TARGEN_EXIT.
|
||||
last=""
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
echo "targen mock: generating $last"
|
||||
if [ "${ICCERY_MOCK_TARGEN_EXIT:-0}" -ne 0 ]; then
|
||||
echo "mock targen failure" >&2
|
||||
exit "$ICCERY_MOCK_TARGEN_EXIT"
|
||||
fi
|
||||
touch "$last.ti1"
|
||||
exit 0
|
||||
@@ -0,0 +1,355 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 2 UI tests — issues #7–#11 (docs/21 element contract).
|
||||
/// Every test launches the app with an isolated `ICCERY_TEST_ROOT`,
|
||||
/// fixture sidecars via `ICCERY_ARGYLL_BINARY_DIR`, and
|
||||
/// `ICCERY_UI_TESTING=1` so file dialogs resolve to env-provided
|
||||
/// paths instead of modal panels. No hardware, no network, no real
|
||||
/// Argyll install, and nothing is written to the developer's app data.
|
||||
@MainActor
|
||||
final class Milestone2UITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
// The xctrunner sandbox only permits writes inside its own
|
||||
// container — the work dir lives there (the app can read/write
|
||||
// it). Executable fixtures, however, must live outside the
|
||||
// container or the app-under-test cannot posix_spawn them, so
|
||||
// `bin` points at the committed Fixtures/bin scripts in the
|
||||
// repo checkout (resolved via #filePath).
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // Tests/ICCeryUITests
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
/// Force the fixture printtarg to exit with `code`.
|
||||
private func failPrinttarg(exitCode: Int) {
|
||||
app.launchEnvironment["ICCERY_MOCK_PRINTTARG_EXIT"] = "\(exitCode)"
|
||||
}
|
||||
|
||||
/// Launch and bring the app to the front — other app windows
|
||||
/// (the IDE, notification banners) covering the test window count
|
||||
/// as "interrupting elements" and stall synthesized clicks.
|
||||
private func launchApp() {
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
|
||||
/// Sheet content on macOS lives under `app.sheets`, outside the
|
||||
/// main window's descendant tree — probe both scopes.
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
|
||||
// Poll both scopes so sheet-hosted elements resolve too.
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
private func staticText(_ exact: String) -> XCUIElement {
|
||||
let inApp = app.staticTexts[exact]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.staticTexts[exact]
|
||||
}
|
||||
|
||||
private func buttonsMatching(_ predicateFormat: String) -> XCUIElementQuery {
|
||||
let pred = NSPredicate(format: predicateFormat)
|
||||
let inApp = app.buttons.matching(pred)
|
||||
if inApp.count > 0 { return inApp }
|
||||
return app.sheets.firstMatch.buttons.matching(pred)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Stage 1 opens with the Standard 800-patch default; Generate stays
|
||||
/// disabled until basename + cwd are valid (issue #7).
|
||||
func testStage1DefaultsAndGenerateGate() throws {
|
||||
launchApp()
|
||||
XCTAssertTrue(waitFor("btnGenerate").exists)
|
||||
XCTAssertTrue(element("patchCountPreset").exists)
|
||||
XCTAssertTrue(element("targetBasename").exists)
|
||||
XCTAssertTrue(element("btnOpenExisting").exists)
|
||||
XCTAssertFalse(app.buttons["btnGenerate"].isEnabled)
|
||||
|
||||
// Browse fills basename + working dir via the test hook.
|
||||
app.buttons["btnBrowse"].click()
|
||||
XCTAssertTrue(app.buttons["btnGenerate"].isEnabled)
|
||||
}
|
||||
|
||||
/// RGB/CMYK + advanced controls expose the documented identifiers
|
||||
/// and the ink-limit group is hidden for RGB (issue #7).
|
||||
func testStage1AdvancedVisibility() throws {
|
||||
launchApp()
|
||||
XCTAssertTrue(waitFor("targenAdvancedDetails").exists)
|
||||
// RGB default: ink-limit group must not exist.
|
||||
XCTAssertFalse(element("targenInkLimitGroup").exists)
|
||||
// The ink-limit group lives inside the Advanced disclosure —
|
||||
// pre-expanded under UI testing (XCUI can't toggle a macOS
|
||||
// DisclosureTriangle reliably). Switch the picker to CMYK.
|
||||
XCTAssertTrue(element("targenAdvancedDetails").exists)
|
||||
let cmyk = app.radioGroups["colourSpace"]
|
||||
.radioButtons["CMYK (RIP output)"]
|
||||
XCTAssertTrue(cmyk.waitForExistence(timeout: 5))
|
||||
cmyk.click()
|
||||
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
|
||||
func testTargenFixtureUnlocksStage2() throws {
|
||||
launchApp()
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti1").path))
|
||||
}
|
||||
|
||||
/// Fixture printtarg → .ti2, gallery page renders, print controls
|
||||
/// stay disabled, Stage 3 advance becomes available (issues #9/#10).
|
||||
func testPrinttargFixtureGalleryAndStubbedPrint() throws {
|
||||
launchApp()
|
||||
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
||||
|
||||
// Colour-management warning is always present on Stage 2.
|
||||
XCTAssertTrue(element("cmWarningBanner").exists)
|
||||
XCTAssertTrue(element("instrumentSelect").exists)
|
||||
XCTAssertTrue(element("pageSizeSelect").exists)
|
||||
XCTAssertTrue(element("tiffDpi").exists)
|
||||
XCTAssertTrue(element("targetLabelPreview").exists)
|
||||
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
|
||||
XCTAssertTrue(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
|
||||
// M3 stubs: visible but inert.
|
||||
XCTAssertTrue(element("rawPrintPanel").exists)
|
||||
XCTAssertFalse(app.buttons["btnPrintAll"].isEnabled)
|
||||
XCTAssertFalse(app.buttons["btnPrintPage-0"].isEnabled)
|
||||
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
|
||||
}
|
||||
|
||||
/// A failed printtarg run stays on Stage 2 (non-zero exit, #156).
|
||||
func testPrinttargFailureStaysOnStage2() throws {
|
||||
failPrinttarg(exitCode: 3)
|
||||
launchApp()
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
|
||||
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
// The notice banner reports the failure and we never advance:
|
||||
// btnCreateLayout is still the stage's action, and no gallery
|
||||
// appears.
|
||||
let failureText = element("noticeText")
|
||||
XCTAssertTrue(failureText.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((failureText.value as? String ?? "")
|
||||
.contains("printtarg failed"))
|
||||
XCTAssertTrue(element("btnCreateLayout").exists)
|
||||
XCTAssertFalse(element("galleryPage-0").exists)
|
||||
XCTAssertFalse(FileManager.default.fileExists(
|
||||
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
|
||||
}
|
||||
|
||||
/// Resume: .ti1 jumps to Stage 2 (issue #8).
|
||||
func testResumeTi1() throws {
|
||||
FileManager.default.createFile(
|
||||
atPath: workDir.appendingPathComponent("old.ti1").path,
|
||||
contents: Data("CGATS".utf8))
|
||||
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
|
||||
workDir.appendingPathComponent("old.ti1").path
|
||||
launchApp()
|
||||
app.buttons["btnOpenExisting"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists)
|
||||
}
|
||||
|
||||
/// Resume: .ti2 with sibling .ti1 reaches the Stage 3 shell and
|
||||
/// shows the persisted "Resumed from .ti2" state (issue #8).
|
||||
func testResumeTi2ShowsStage3AndNotice() throws {
|
||||
FileManager.default.createFile(
|
||||
atPath: workDir.appendingPathComponent("old.ti1").path,
|
||||
contents: Data("CGATS".utf8))
|
||||
FileManager.default.createFile(
|
||||
atPath: workDir.appendingPathComponent("old.ti2").path,
|
||||
contents: Data("""
|
||||
CTI2
|
||||
TARGET_INSTRUMENT "i1"
|
||||
NUMBER_OF_SETS 4
|
||||
NUMBER_OF_PAGES 1
|
||||
BEGIN_DATA_FORMAT
|
||||
""".utf8))
|
||||
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
|
||||
workDir.appendingPathComponent("old.ti2").path
|
||||
launchApp()
|
||||
app.buttons["btnOpenExisting"].click()
|
||||
XCTAssertTrue(waitFor("stage3TargetBasename", timeout: 10).exists)
|
||||
XCTAssertTrue(element("stage3LoadedTargetBanner").exists)
|
||||
let notice = element("noticeText")
|
||||
XCTAssertTrue(notice.exists)
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Resumed from .ti2"))
|
||||
}
|
||||
|
||||
/// A .ti2 without its sibling .ti1 must not advance (issue #8).
|
||||
func testResumeTi2WithoutSiblingFails() throws {
|
||||
FileManager.default.createFile(
|
||||
atPath: workDir.appendingPathComponent("orphan.ti2").path,
|
||||
contents: Data("CTI2".utf8))
|
||||
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
|
||||
workDir.appendingPathComponent("orphan.ti2").path
|
||||
launchApp()
|
||||
app.buttons["btnOpenExisting"].click()
|
||||
let err = element("noticeText")
|
||||
XCTAssertTrue(err.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((err.value as? String ?? "").contains("Cannot resume"))
|
||||
XCTAssertTrue(element("btnGenerate").exists) // still Stage 1
|
||||
}
|
||||
|
||||
/// Preset apply is bidirectional: the draft preset's 150 dpi must
|
||||
/// be visible on Stage 2; built-ins cannot be deleted (issue #11).
|
||||
func testPresetApplyAndBuiltInProtection() throws {
|
||||
// Land on Stage 2 via a .ti1 resume so tiffDpi is visible.
|
||||
FileManager.default.createFile(
|
||||
atPath: workDir.appendingPathComponent("p.ti1").path,
|
||||
contents: Data("CGATS".utf8))
|
||||
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
|
||||
workDir.appendingPathComponent("p.ti1").path
|
||||
launchApp()
|
||||
|
||||
// Sidebar preset picker is enabled; apply the draft preset.
|
||||
let picker = app.popUpButtons["presetSelect"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(picker.isEnabled)
|
||||
picker.click()
|
||||
let draftItem = app.menuItems["Fast RGB Draft (400 patches)"]
|
||||
XCTAssertTrue(draftItem.waitForExistence(timeout: 5))
|
||||
draftItem.click()
|
||||
|
||||
app.buttons["btnOpenExisting"].click()
|
||||
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists)
|
||||
// StaticText content is exposed via `value` on macOS, not `label`.
|
||||
XCTAssertTrue(app.staticTexts
|
||||
.matching(NSPredicate(format: "value CONTAINS 'DPI: 150'"))
|
||||
.firstMatch.waitForExistence(timeout: 5))
|
||||
|
||||
// Manage dialog: built-ins show "Built-in" and have no delete.
|
||||
app.buttons["btnOpenPresetsDialog"].click()
|
||||
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
|
||||
XCTAssertFalse(element("btnDeletePreset-preset-std-rgb").exists)
|
||||
XCTAssertTrue(element("presetRow-preset-std-rgb").exists)
|
||||
element("btnCloseManagePresetsDialog").click()
|
||||
}
|
||||
|
||||
/// Save a custom preset through the dialog; it appears in the list
|
||||
/// and can be deleted (issue #11).
|
||||
func testSaveAndDeleteCustomPreset() throws {
|
||||
launchApp()
|
||||
app.buttons["btnSavePresetModal"].click()
|
||||
XCTAssertTrue(waitFor("savePresetDialog", timeout: 10).exists)
|
||||
let nameField = element("savePresetName")
|
||||
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
|
||||
nameField.click()
|
||||
nameField.typeText("UI Test Preset")
|
||||
element("btnConfirmSavePreset").click()
|
||||
|
||||
app.buttons["btnOpenPresetsDialog"].click()
|
||||
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
|
||||
XCTAssertTrue(staticText("UI Test Preset")
|
||||
.waitForExistence(timeout: 5))
|
||||
// The custom row is deletable (id prefix custom-).
|
||||
let deleteButtons = buttonsMatching(
|
||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
|
||||
deleteButtons.firstMatch.click()
|
||||
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
|
||||
}
|
||||
|
||||
/// Export a preset to JSON and re-import it (issue #11).
|
||||
func testPresetExportImport() throws {
|
||||
let exportURL = testRoot.appendingPathComponent("export.json")
|
||||
let importURL = testRoot.appendingPathComponent("import.json")
|
||||
app.launchEnvironment["ICCERY_TEST_PRESET_EXPORT"] = exportURL.path
|
||||
app.launchEnvironment["ICCERY_TEST_PRESET_IMPORT"] = importURL.path
|
||||
launchApp()
|
||||
|
||||
// Save a custom preset first, then export it.
|
||||
app.buttons["btnSavePresetModal"].click()
|
||||
let nameField = element("savePresetName")
|
||||
XCTAssertTrue(nameField.waitForExistence(timeout: 10))
|
||||
nameField.click()
|
||||
nameField.typeText("RoundTrip")
|
||||
element("btnConfirmSavePreset").click()
|
||||
|
||||
// Export via the manage dialog.
|
||||
app.buttons["btnOpenPresetsDialog"].click()
|
||||
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
|
||||
let exportButtons = buttonsMatching(
|
||||
"identifier BEGINSWITH 'btnExportPreset-'")
|
||||
XCTAssertTrue(exportButtons.firstMatch.waitForExistence(timeout: 5))
|
||||
exportButtons.firstMatch.click()
|
||||
XCTAssertTrue(waitForFile(exportURL), "preset export file missing")
|
||||
|
||||
// Import must land back in the store (delete → re-import).
|
||||
let deleteButtons = buttonsMatching(
|
||||
"identifier BEGINSWITH 'btnDeletePreset-'")
|
||||
deleteButtons.firstMatch.click()
|
||||
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
|
||||
|
||||
// Copy the export to the import path so the hook picks it up.
|
||||
try FileManager.default.copyItem(at: exportURL, to: importURL)
|
||||
element("btnImportPreset").click()
|
||||
XCTAssertTrue(staticText("RoundTrip")
|
||||
.waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
|
||||
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
|
||||
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
|
||||
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends
|
||||
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the
|
||||
/// evidence that captured options are replayed (docs/11 §tests).
|
||||
@MainActor
|
||||
final class Milestone3UITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui3-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func launchApp() {
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
/// Drive the app through targen + printtarg so the print panel is
|
||||
/// live with a manifest.
|
||||
private func reachPrintPanel() {
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
_ = waitFor("galleryPage-0", timeout: 25)
|
||||
}
|
||||
|
||||
private func recordedLpArgv() -> String {
|
||||
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let out = recordedLpArgv()
|
||||
if !out.isEmpty { return out }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return recordedLpArgv()
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Panel appears after the manifest; refresh populates the printer
|
||||
/// select with the mock queues and shows a status badge.
|
||||
func testPrintPanelEnumeratesPrinters() throws {
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
|
||||
XCTAssertTrue(waitFor("rawPrintPanel").exists)
|
||||
// The panel auto-refreshes on appear; the default mock queue is
|
||||
// selected and its status badge shows.
|
||||
XCTAssertTrue(element("printerSelect").waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(element("printerStatusBadge")
|
||||
.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(element("printerTraySelect").exists)
|
||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
||||
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||
}
|
||||
|
||||
/// Preferences cancel → info notice, no error, no cache mutation.
|
||||
func testPreferencesCancelIsInfo() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "cancel"
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("cancelled"))
|
||||
}
|
||||
|
||||
/// Preferences OK → captured options are replayed verbatim in the
|
||||
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's
|
||||
/// acceptance test: "captured options replayed in argv").
|
||||
func testCapturedOptionsReplayedInLpArgv() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"InputSlot=Rear MediaType=PhotographicGlossy"
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains(
|
||||
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||
XCTAssertTrue(argv.contains(
|
||||
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv)
|
||||
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||
// Detected bypass for the mock queue (EPIJ_CMat present in
|
||||
// lpoptions -l) is appended when not captured.
|
||||
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv)
|
||||
XCTAssertTrue(argv.contains("orientation-requested=3"), argv)
|
||||
// Last token is the TIFF.
|
||||
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasSuffix("page1.tif"), argv)
|
||||
}
|
||||
|
||||
/// Per-page print uses the same spool path (btnPrintPage-N).
|
||||
func testPerPagePrint() throws {
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
app.buttons["btnPrintPage-0"].click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||
}
|
||||
|
||||
/// lp failure surfaces in the in-panel notice, not the wizard banner.
|
||||
func testLpFailureShowsPrintNotice() throws {
|
||||
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1"
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Print failed"))
|
||||
}
|
||||
|
||||
/// wizardState.printerName records the queue used for spooling (#95).
|
||||
func testPrinterNamePersistedOnSpool() throws {
|
||||
launchApp()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
_ = waitForLpLine()
|
||||
let stateURL = testRoot
|
||||
.appendingPathComponent("AppData")
|
||||
.appendingPathComponent("wizard_state.json")
|
||||
XCTAssertTrue(waitForFile(stateURL))
|
||||
let data = try Data(contentsOf: stateURL)
|
||||
let state = String(data: data, encoding: .utf8) ?? ""
|
||||
XCTAssertTrue(state.contains("Mock_Epson_7450"), state)
|
||||
}
|
||||
|
||||
private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
+34
@@ -19,9 +19,25 @@ targets:
|
||||
- path: Resources
|
||||
excludes:
|
||||
- ICCery.entitlements
|
||||
- Argyll
|
||||
- path: Resources/Argyll
|
||||
type: folder
|
||||
dependencies:
|
||||
- package: ICCeryCore
|
||||
product: ICCeryCore
|
||||
postBuildScripts:
|
||||
- name: Copy Argyll sidecars
|
||||
script: |
|
||||
set -e
|
||||
SRC="${SRCROOT}/Vendor/Argyll"
|
||||
DEST="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Argyll"
|
||||
if [ -d "$SRC" ]; then
|
||||
mkdir -p "$DEST"
|
||||
rsync -a "$SRC/" "$DEST/"
|
||||
else
|
||||
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
|
||||
fi
|
||||
basedOnDependencyAnalysis: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
|
||||
@@ -65,12 +81,29 @@ targets:
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
|
||||
ICCeryUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
sources:
|
||||
- path: Tests/ICCeryUITests
|
||||
dependencies:
|
||||
- target: ICCery
|
||||
settings:
|
||||
base:
|
||||
TEST_TARGET_NAME: ICCery
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
CODE_SIGN_IDENTITY: "-"
|
||||
SWIFT_VERSION: "6.0"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
|
||||
schemes:
|
||||
ICCery:
|
||||
build:
|
||||
targets:
|
||||
ICCery: all
|
||||
ICCeryCoreTests: [test]
|
||||
ICCeryUITests: [test]
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
@@ -78,3 +111,4 @@ schemes:
|
||||
gatherCoverageData: false
|
||||
targets:
|
||||
- ICCeryCoreTests
|
||||
- ICCeryUITests
|
||||
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/sh
|
||||
# scripts/fetch-argyll.sh
|
||||
#
|
||||
# Downloads the Gronod ArgyllCMS fork release (macOS universal binaries)
|
||||
# into Vendor/Argyll/. POSIX sh + curl + tar — no Node dependency.
|
||||
#
|
||||
# Env overrides (parity with v1 fetch-argyll.mjs):
|
||||
# ARGYLL_SERVER_URL default https://git.i3omb.com
|
||||
# ARGYLL_REPO default gronod/argyllcms
|
||||
# ARGYLL_RELEASE_TAG default: latest release
|
||||
# GITEA_TOKEN optional, for private repos
|
||||
#
|
||||
# Layout produced (docs/04 §0.6, docs/02 §Sidecar layout):
|
||||
# Vendor/Argyll/macos-universal/<tools> # marker binary: instlist
|
||||
# Mocks and reference_gamuts are tracked under Resources/Argyll/ —
|
||||
# they ship in git, not in the release tarball.
|
||||
|
||||
set -eu
|
||||
|
||||
SERVER="${ARGYLL_SERVER_URL:-https://git.i3omb.com}"
|
||||
REPO="${ARGYLL_REPO:-gronod/argyllcms}"
|
||||
TAG="${ARGYLL_RELEASE_TAG:-}"
|
||||
SUFFIX="_macOS_universal_bin.tgz"
|
||||
PLATFORM_DIR="macos-universal"
|
||||
MARKER="instlist"
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DEST="$ROOT/Vendor/Argyll/$PLATFORM_DIR"
|
||||
|
||||
FORCE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
*) echo "usage: $0 [--force]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$FORCE" -eq 0 ] && [ -x "$DEST/$MARKER" ]; then
|
||||
echo "ArgyllCMS binaries already present at $DEST (use --force to re-download)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
AUTH_HEADER=""
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
|
||||
fi
|
||||
|
||||
api_get() {
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fsSL -H 'Accept: application/json' -H "$AUTH_HEADER" "$1"
|
||||
else
|
||||
curl -fsSL -H 'Accept: application/json' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "$TAG" ]; then
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/tags/$TAG"
|
||||
else
|
||||
API_URL="$SERVER/api/v1/repos/$REPO/releases/latest"
|
||||
fi
|
||||
|
||||
echo "Fetching release info from $API_URL"
|
||||
RELEASE_JSON="$(api_get "$API_URL")" || {
|
||||
echo "error: failed to fetch release info (set GITEA_TOKEN if the repo is private)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find the macOS universal asset's browser_download_url without jq.
|
||||
ASSET_URL="$(printf '%s' "$RELEASE_JSON" \
|
||||
| tr ',' '\n' \
|
||||
| grep '"browser_download_url"' \
|
||||
| grep "$SUFFIX" \
|
||||
| sed -E 's/.*"browser_download_url"[^"]*"([^"]+)".*/\1/' \
|
||||
| head -n 1)"
|
||||
|
||||
if [ -z "$ASSET_URL" ]; then
|
||||
echo "error: no release asset matching '*$SUFFIX' on $API_URL" >&2
|
||||
echo "looked-for pattern: Argyll_<tag>_<sha>$SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading $ASSET_URL"
|
||||
TMPDIR_FETCH="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_FETCH"' EXIT
|
||||
ARCHIVE="$TMPDIR_FETCH/argyll.tgz"
|
||||
|
||||
if [ -n "$AUTH_HEADER" ]; then
|
||||
curl -fSL -o "$ARCHIVE" -H "$AUTH_HEADER" "$ASSET_URL"
|
||||
else
|
||||
curl -fSL -o "$ARCHIVE" "$ASSET_URL"
|
||||
fi
|
||||
|
||||
EXTRACT="$TMPDIR_FETCH/extract"
|
||||
mkdir -p "$EXTRACT"
|
||||
tar -xzf "$ARCHIVE" -C "$EXTRACT"
|
||||
|
||||
# Archive contains Argyll_V*/bin/ (or a bare bin/).
|
||||
BIN_DIR=""
|
||||
for d in "$EXTRACT"/Argyll_V*/bin "$EXTRACT"/bin; do
|
||||
if [ -d "$d" ]; then BIN_DIR="$d"; break; fi
|
||||
done
|
||||
if [ -z "$BIN_DIR" ]; then
|
||||
echo "error: archive has no Argyll_V*/bin or bin/ directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp -R "$BIN_DIR"/. "$DEST"/
|
||||
find "$DEST" -type f -exec chmod 0755 {} +
|
||||
# Downloads carry com.apple.quarantine; the app cannot spawn quarantined tools.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
|
||||
# Ad-hoc sign every Mach-O (#165: unsigned arm64 → "Killed: 9"), then
|
||||
# verify — an unsigned sidecar fails the script.
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
codesign -f -s - "$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
UNSIGNED=""
|
||||
for f in "$DEST"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
if file -b "$f" | grep -q 'Mach-O'; then
|
||||
if ! codesign -dvv "$f" >/dev/null 2>&1; then
|
||||
UNSIGNED="$UNSIGNED $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -n "$UNSIGNED" ]; then
|
||||
echo "error: unsigned binaries remain:$UNSIGNED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$DEST/$MARKER" ]; then
|
||||
echo "error: marker binary $MARKER missing after extraction" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: $(ls "$DEST" | wc -l | tr -d ' ') tools installed to $DEST"
|
||||
Reference in New Issue
Block a user