Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64393b7591 | ||
|
|
cc184bfff3 | ||
|
|
bb6ca957ba | ||
|
|
ad6f8247d2 | ||
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 | ||
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
14f521a65e | ||
|
|
4281d07754 | ||
|
|
71172d751a | ||
|
|
73db1c8c25 | ||
|
|
7c1303ac11 | ||
|
|
797d30b023 | ||
|
|
65ba6dc61a | ||
|
|
c0f5fb8c28 | ||
|
|
dc62f5c016 | ||
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc | ||
|
|
716b302374 | ||
|
|
20d6bf7fdc | ||
|
|
5bb057a1e3 | ||
|
|
b998b48abf | ||
|
|
4440a26476 | ||
|
|
4c2863e52c |
+22
@@ -0,0 +1,22 @@
|
||||
# Xcode
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.xcscmblueprint
|
||||
*.xccrashreport
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
|
||||
# Fetched Argyll sidecars (release artefacts, not git blobs — #127)
|
||||
Vendor/Argyll/
|
||||
|
||||
# XcodeGen output (regenerate with `make gen`)
|
||||
ICCery.xcodeproj/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,55 @@
|
||||
# AGENTS.md — ICCery v2 Mac
|
||||
|
||||
## Product
|
||||
Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5.0 fork as AGPL-isolated subprocesses. Spec snapshot lives in `docs/` (chapters 01–25). Ticket plan: `BUILD-PLAN.md`.
|
||||
|
||||
## Stack
|
||||
- SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing / panels / file dialogs.
|
||||
- Minimum macOS **14.0**. Universal `arm64` + `x86_64`.
|
||||
- Bundle id **`com.gronod.iccery2`**. Product name ICCery.
|
||||
- App Sandbox **OFF**. Hardened Runtime **ON**. Entitlements in `ICCery.entitlements`.
|
||||
- No Tauri, no Rust host, no WKWebView, no Three.js.
|
||||
|
||||
## Package layout
|
||||
- `ICCery` — app target (SwiftUI shell).
|
||||
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel).
|
||||
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
|
||||
|
||||
## AGPL boundary
|
||||
Never link Argyll. Spawn only.
|
||||
- Streaming: `ProcessManager` actor (`targen`, `printtarg`, `chartread`, `average`, `colprof`, `profcheck`, `iccgamut`, `instlist`).
|
||||
- Captured: `runCaptured` (`printcal`, `applycal` only).
|
||||
Both paths set `ARGYLL_NOT_INTERACTIVE=1`. Never search `$PATH` for binaries.
|
||||
|
||||
## Concurrency
|
||||
No blocking subprocess I/O on `@MainActor`.
|
||||
Do not hop to main per stdout line (colprof emits thousands of `.`).
|
||||
Stdin handle is independent of wait (#84). Process ids are exclusive leases (#116).
|
||||
`killAll` on `NSApplication.willTerminate` and last-window close (#147, #149).
|
||||
XY cancel: send `q\n`, wait ~500 ms, then kill.
|
||||
|
||||
## Argyll flag discipline
|
||||
See `docs/25-rewrite-notes.md` and `docs/04-argyll-binaries.md` §15.
|
||||
`-d` / `-r` / `-R` / `-u` / `-Y` / `-c` mean different things per tool.
|
||||
v2.0 `-u` policy: printtarg + chartread + profcheck only.
|
||||
|
||||
## Files
|
||||
Artefact gating on disk. No placeholder basenames (#60).
|
||||
Empty cwd illegal (#59). Atomic writes = `.tmp` + rename (#213).
|
||||
User-supplied strings via SwiftUI `Text` only (#114).
|
||||
TIFF never rendered directly — host-side PNG preview (#58).
|
||||
|
||||
## Branching
|
||||
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
|
||||
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
|
||||
|
||||
## Verify
|
||||
```
|
||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
codesign -dvv <sidecar>
|
||||
```
|
||||
|
||||
## Private ColorSync SPI
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||
`lp` path and Quartz/`ICCeryPrintKit` path use **different** ColorSync dictionaries. Never mix.
|
||||
@@ -0,0 +1,48 @@
|
||||
# BUILD-PLAN.md — ICCery v2 Mac
|
||||
|
||||
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
|
||||
|
||||
## Sprint rule
|
||||
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
|
||||
Hardware gates block *release of that sprint*, not filing, and not starting coding of the next sprint's non-dependent tickets.
|
||||
|
||||
## Milestone map
|
||||
|
||||
| Id | Name | Issues | CI/mock gate | Hardware gate |
|
||||
|----|------|--------|--------------|---------------|
|
||||
| M1 | Foundation & process core | 1–6 | App launches; wizard shell; ProcessManager + `runCaptured`; artefact gating tests; settings persist + dialog | N/A |
|
||||
| M2 | Target generation & layout | 7–11 | targen → `.ti1`; printtarg → `.ti2`+TIFF; manifest+gallery; resume; presets | N/A |
|
||||
| M3 | Unmanaged printing (`lp`) | 12–15, 17 | parsers; `build_lp_args` goldens (both `AP_*`); cancel → nil | Preferences shows driver PDE; unmanaged page on Epson or Canon |
|
||||
| M4 | Measurement | 18–22 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
|
||||
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
|
||||
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
|
||||
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
|
||||
|
||||
Issue **16 is not an M3 or M6 exit gate.**
|
||||
|
||||
## Branch taxonomy
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
main[main]
|
||||
develop[develop]
|
||||
m1["milestone/m1-foundation"]
|
||||
m2["milestone/m2-targets"]
|
||||
feat["feat/7-targen-argv"]
|
||||
main --> develop
|
||||
develop --> m1
|
||||
develop --> m2
|
||||
m2 --> feat
|
||||
```
|
||||
|
||||
Quoted node labels are required (v0.8.5 #80).
|
||||
|
||||
## Command surface (parity with v0.8.5, native names)
|
||||
|
||||
Process: `spawn`, `sendStdin`, `kill`, `killAll`, `resolveBinary`, `runCaptured`.
|
||||
Files: dedicated picker per purpose; `readTiffPreviewPng`; `parseTi2Header`.
|
||||
Wizard: `verifyStageArtefacts`, `getProfilePath`, `snapshotTi3`, `promoteTi3`.
|
||||
Runners: `runTargen`, `runPrinttarg`, `runChartread`, `runAverage`, `runColprof`, `runProfcheck`, `extractGamut`, `detectInstruments`.
|
||||
Cal: `generateCalibrationTarget`, `computeCalibrationCurves`, `applyCalibration`, `parseCalFile`, library + project state.
|
||||
Print: `getPrinters`, `getPrinterCapabilities`, `showPrinterProperties`, `printTargetNative`.
|
||||
Install / quality / settings / CGATS: same semantics as `docs/25-rewrite-notes.md` host command list.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# LICENCE
|
||||
|
||||
**Copyright (c) 2026 Gordon Bolton**
|
||||
**All Rights Reserved.**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), strictly to view the source code and execute the Software for the sole purpose of personal testing, evaluation, and providing feedback.
|
||||
|
||||
Under this licence, you may **not**:
|
||||
|
||||
* Modify, alter, or create derivative works of the Software.
|
||||
* Distribute, publish, or sublicense the Software or any derivatives.
|
||||
* Use the Software for any commercial or production purpose.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Bundled ArgyllCMS sidecar binaries
|
||||
|
||||
This application bundles and invokes command-line binaries from the Gronod fork of ArgyllCMS. Those binaries are licensed separately under the **GNU Affero General Public License v3 (AGPLv3)**. They are executed strictly as independent subprocesses — they are never linked, loaded, or incorporated into this application — and a copy of `License.txt` is shipped beside the binaries in `Resources/argyll/`. The terms above apply only to the ICCery application source code, not to the ArgyllCMS binaries.
|
||||
@@ -0,0 +1,23 @@
|
||||
SCHEME := ICCery
|
||||
DEST := 'platform=macOS'
|
||||
|
||||
.PHONY: gen build test universal fetch-argyll clean
|
||||
|
||||
gen:
|
||||
xcodegen generate
|
||||
|
||||
build: gen
|
||||
xcodebuild build -scheme $(SCHEME) -destination $(DEST)
|
||||
|
||||
test: gen
|
||||
xcodebuild build test -scheme $(SCHEME) -destination $(DEST)
|
||||
|
||||
universal: gen
|
||||
xcodebuild build -scheme $(SCHEME) -destination $(DEST) \
|
||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
||||
|
||||
fetch-argyll:
|
||||
scripts/fetch-argyll.sh
|
||||
|
||||
clean:
|
||||
rm -rf ICCery.xcodeproj DerivedData Packages/ICCeryCore/.build
|
||||
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ICCeryCore",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ICCeryCore"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,513 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from `ArgyllRunner` executions.
|
||||
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||
case processFailed(code: Int32, logs: [String])
|
||||
case missingArtefact(String)
|
||||
case malformedManifest(String)
|
||||
case instrumentDetectionFailed(String)
|
||||
case chartreadFailed(String)
|
||||
case averageFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .processFailed(let code, _):
|
||||
return "Process exited with code \(code)"
|
||||
case .missingArtefact(let path):
|
||||
return "Expected output file was not created: \(path)"
|
||||
case .malformedManifest(let reason):
|
||||
return "Failed to parse printtarg manifest: \(reason)"
|
||||
case .instrumentDetectionFailed(let reason):
|
||||
return "Instrument detection failed: \(reason)"
|
||||
case .chartreadFailed(let reason):
|
||||
return "Chartread failed: \(reason)"
|
||||
case .averageFailed(let reason):
|
||||
return "Averaging failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// MARK: - instlist (Stage 3 detection)
|
||||
|
||||
/// Runs `instlist` and returns the detected devices.
|
||||
///
|
||||
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
|
||||
/// fallback constrained to known instrument tokens is used.
|
||||
public func detectInstruments() async throws -> [InstrumentDevice] {
|
||||
let binaryURL = binaryResolver.resolve("instlist")
|
||||
let processId = ProcessID.instlist
|
||||
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: [],
|
||||
workingDirectory: nil
|
||||
)
|
||||
|
||||
var accumulator = JSONAccumulator()
|
||||
var stdout = ""
|
||||
var stderr: [String] = []
|
||||
var exitCode: Int32?
|
||||
|
||||
for await event in events {
|
||||
guard event.id == processId else { continue }
|
||||
switch event {
|
||||
case .stdout(_, let line):
|
||||
stdout += line + "\n"
|
||||
_ = accumulator.feed(line: line)
|
||||
case .stderr(_, let line):
|
||||
stderr.append(line)
|
||||
case .exit(_, let code):
|
||||
exitCode = code
|
||||
default:
|
||||
break
|
||||
}
|
||||
if exitCode != nil { break }
|
||||
}
|
||||
|
||||
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
|
||||
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
|
||||
return devices
|
||||
}
|
||||
}
|
||||
|
||||
if let code = exitCode, code != 0, stderr.isEmpty == false {
|
||||
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
// Final fallback: try to parse the raw stdout as a text document.
|
||||
if let devices = try? InstrumentParser.parse(stdout) {
|
||||
return devices
|
||||
}
|
||||
|
||||
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
|
||||
}
|
||||
|
||||
// MARK: - average (Stage 3 multi-pass finish)
|
||||
|
||||
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
|
||||
public func runAverage(
|
||||
config: AverageConfig,
|
||||
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||
) async throws -> URL {
|
||||
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
let args = try AverageArgs.build(config: config)
|
||||
let binaryURL = binaryResolver.resolve("average")
|
||||
let processId = ProcessID.average(config.basename)
|
||||
|
||||
let events = processManager.events()
|
||||
try await processManager.runStreaming(
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||
|
||||
guard run.exitCode == 0 else {
|
||||
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
||||
}
|
||||
|
||||
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
||||
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
// MARK: - chartread (Stage 3 interactive)
|
||||
|
||||
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||
///
|
||||
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
|
||||
/// are all handled here. Use `sendChartreadInput` to drive the child and
|
||||
/// `cancelChartread` to terminate it.
|
||||
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
|
||||
let cleanBasename: String
|
||||
let cwd: URL
|
||||
do {
|
||||
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let args: [String]
|
||||
do {
|
||||
args = try ChartreadArgs.build(config: config)
|
||||
} catch {
|
||||
return AsyncStream { continuation in
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
let binaryURL = binaryResolver.resolve("chartread")
|
||||
let processId = ProcessID.chartread(cleanBasename)
|
||||
let processManager = self.processManager
|
||||
|
||||
return AsyncStream { continuation in
|
||||
let task = Task {
|
||||
let events = processManager.events()
|
||||
|
||||
do {
|
||||
try await processManager.runStreaming(
|
||||
id: processId,
|
||||
binary: binaryURL,
|
||||
arguments: args,
|
||||
workingDirectory: cwd
|
||||
)
|
||||
} catch {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
var state: ChartreadState = .idle
|
||||
var pendingLogs: [String] = []
|
||||
var lastFlush = Date()
|
||||
var exitCode: Int32?
|
||||
|
||||
func flushLogs() {
|
||||
guard !pendingLogs.isEmpty else { return }
|
||||
let batch = pendingLogs
|
||||
pendingLogs.removeAll(keepingCapacity: true)
|
||||
continuation.yield(.log(batch))
|
||||
}
|
||||
|
||||
for await event in events {
|
||||
guard event.id == processId else { continue }
|
||||
|
||||
switch event {
|
||||
case .stdout(_, let line):
|
||||
let classified = ChartreadClassifier.classify(line: line, previousState: state)
|
||||
state = classified.state
|
||||
if classified.isRemoveSheetNotice {
|
||||
continuation.yield(.removeSheetNotice)
|
||||
}
|
||||
if classified.sheetNumber != nil || classified.alignmentPatch != nil {
|
||||
continuation.yield(.prompt(classified))
|
||||
} else if state != previousOrContinuationState(state, classified) {
|
||||
// Only emit prompt when the state meaningfully changes.
|
||||
continuation.yield(.prompt(classified))
|
||||
} else if state == .tablePlaceSheet || state == .tableAlign {
|
||||
// Continuation lines in table states are still prompts.
|
||||
continuation.yield(.prompt(classified))
|
||||
} else if classified.requestedWarningKey != nil {
|
||||
continuation.yield(.prompt(classified))
|
||||
}
|
||||
|
||||
pendingLogs.append(line)
|
||||
|
||||
case .stderr(_, let line):
|
||||
pendingLogs.append(line)
|
||||
|
||||
case .jsonRow(_, let payload):
|
||||
do {
|
||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
|
||||
state = row.isFinalRow ? .allStripsRead : state
|
||||
continuation.yield(.row(row))
|
||||
} catch {
|
||||
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
case .error(_, let message):
|
||||
pendingLogs.append("Error: \(message)")
|
||||
|
||||
case .exit(_, let code):
|
||||
exitCode = code
|
||||
}
|
||||
|
||||
if exitCode == nil,
|
||||
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||
flushLogs()
|
||||
lastFlush = Date()
|
||||
}
|
||||
|
||||
if exitCode != nil {
|
||||
flushLogs()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||
if let code = exitCode, code == 0 {
|
||||
if FileManager.default.fileExists(atPath: canonical.path) {
|
||||
continuation.yield(.completed(canonical))
|
||||
} else {
|
||||
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||
}
|
||||
} else {
|
||||
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previousOrContinuationState(_ state: ChartreadState, _ classified: ChartreadClassifyResult) -> ChartreadState {
|
||||
if classified.isTableContinuation { return .promptContinue }
|
||||
return state
|
||||
}
|
||||
|
||||
/// Send an exact input sequence to the running `chartread` child.
|
||||
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||
let processId = ProcessID.chartread(cleanBasename)
|
||||
try await processManager.sendStdin(id: processId, bytes: input.bytes)
|
||||
}
|
||||
|
||||
/// Terminate a running `chartread` child.
|
||||
///
|
||||
/// For XY tables, sends `q\n` first and waits ~500 ms so the head parks.
|
||||
public func cancelChartread(basename: String, isXY: Bool = false) {
|
||||
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
|
||||
guard let cleanBasename else { return }
|
||||
let processId = ProcessID.chartread(cleanBasename)
|
||||
|
||||
Task {
|
||||
if isXY {
|
||||
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
await processManager.kill(id: processId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by a running `chartread` session.
|
||||
public enum ChartreadEvent: Sendable {
|
||||
/// Classified prompt / state update.
|
||||
case prompt(ChartreadClassifyResult)
|
||||
/// A decoded `ROW_COLORS_JSON` row.
|
||||
case row(ChartreadRow)
|
||||
/// A batched log chunk (stdout + stderr lines).
|
||||
case log([String])
|
||||
/// Informational "remove last sheet" notice.
|
||||
case removeSheetNotice
|
||||
/// Process exited with the given code.
|
||||
case exit(Int32)
|
||||
/// Successful completion with the canonical `.ti3` URL.
|
||||
case completed(URL)
|
||||
/// Failure (non-zero exit, missing artefact, spawn/parse error).
|
||||
case failed(ArgyllRunnerError)
|
||||
}
|
||||
|
||||
/// Exact bytes sent to `chartread` stdin.
|
||||
public enum ChartreadInput: Sendable {
|
||||
case trigger // " \n"
|
||||
case accept // "\n"
|
||||
case done // "d\n"
|
||||
case quit // "q\n"
|
||||
case customKey(String)
|
||||
|
||||
public var bytes: Data {
|
||||
switch self {
|
||||
case .trigger:
|
||||
return Data(" \n".utf8)
|
||||
case .accept:
|
||||
return Data("\n".utf8)
|
||||
case .done:
|
||||
return Data("d\n".utf8)
|
||||
case .quit:
|
||||
return Data("q\n".utf8)
|
||||
case .customKey(let key):
|
||||
return Data("\(key)\n".utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Severity levels, matching the v1 `log_level` setting values.
|
||||
public enum LogLevel: String, Codable, Sendable, CaseIterable {
|
||||
case error, warn, info, debug, trace
|
||||
|
||||
var osType: OSLogType {
|
||||
switch self {
|
||||
case .error: return .error
|
||||
case .warn: return .default
|
||||
case .info: return .info
|
||||
case .debug: return .debug
|
||||
case .trace: return .debug
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .error: return 0
|
||||
case .warn: return 1
|
||||
case .info: return 2
|
||||
case .debug: return 3
|
||||
case .trace: return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, sink: LogSink = .shared) {
|
||||
self.category = category
|
||||
self.sink = sink
|
||||
self.osLog = Logger(
|
||||
subsystem: AppPaths.bundleIdentifier,
|
||||
category: category
|
||||
)
|
||||
}
|
||||
|
||||
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()) }
|
||||
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
|
||||
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
|
||||
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// Rewrites the user's home directory to `~` in log output
|
||||
/// (docs/03 §Logging hygiene — `sanitize_arg_for_logging`).
|
||||
public enum LogSanitizer {
|
||||
/// Replaces every occurrence of the current user's home path with `~`.
|
||||
public static func sanitize(_ text: String) -> String {
|
||||
let home = NSHomeDirectory()
|
||||
guard !home.isEmpty else { return text }
|
||||
return text.replacingOccurrences(of: home, with: "~")
|
||||
}
|
||||
|
||||
/// Sanitizes an argv list for display.
|
||||
public static func sanitizeArgs(_ args: [String]) -> String {
|
||||
args.map(sanitize).joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Rolling file sink for `AppLogger` — `~/Library/Logs/<bundle>/
|
||||
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
|
||||
/// (`iccery.log.1` … `iccery.log.5`).
|
||||
///
|
||||
/// The minimum level is **runtime state** (#158): `setLevel` takes
|
||||
/// effect immediately — at startup and on every settings save.
|
||||
public final class LogSink: @unchecked Sendable {
|
||||
|
||||
public static let shared = LogSink(fileURL: AppPaths.logFile)
|
||||
|
||||
private let lock = NSLock()
|
||||
private let fileURL: URL
|
||||
private var minimumLevel: LogLevel
|
||||
private var handle: FileHandle?
|
||||
|
||||
/// 5 MiB per segment, 5 historical segments kept.
|
||||
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
|
||||
public static let keptSegments = 5
|
||||
|
||||
public init(
|
||||
fileURL: URL = AppPaths.logFile,
|
||||
minimumLevel: LogLevel? = nil
|
||||
) {
|
||||
self.fileURL = fileURL
|
||||
#if DEBUG
|
||||
self.minimumLevel = minimumLevel ?? .debug
|
||||
#else
|
||||
self.minimumLevel = minimumLevel ?? .info
|
||||
#endif
|
||||
}
|
||||
|
||||
public var level: LogLevel {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return minimumLevel
|
||||
}
|
||||
|
||||
/// Applied at startup AND on every settings save (issue #5, #158).
|
||||
public func setLevel(_ level: LogLevel) {
|
||||
lock.lock()
|
||||
minimumLevel = level
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// `nil` → DEBUG-build default (.debug) / release (.info).
|
||||
public func applySettings(_ settings: AppSettings) {
|
||||
setLevel(settings.effectiveLogLevel)
|
||||
}
|
||||
|
||||
public func shouldLog(_ level: LogLevel) -> Bool {
|
||||
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
|
||||
/// rotating first when the active segment exceeds 5 MiB.
|
||||
public func write(level: LogLevel, category: String, message: String) {
|
||||
guard shouldLog(level) else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
rotateIfNeeded()
|
||||
openIfNeeded()
|
||||
let stamp = Self.timestamp()
|
||||
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
handle?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
private static let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static func timestamp() -> String {
|
||||
formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func openIfNeeded() {
|
||||
guard handle == nil else { return }
|
||||
try? FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
||||
}
|
||||
handle = try? FileHandle(forWritingTo: fileURL)
|
||||
try? handle?.seekToEnd()
|
||||
}
|
||||
|
||||
/// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the
|
||||
/// writer. Oldest segment is deleted.
|
||||
private func rotateIfNeeded() {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path),
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
||||
let size = attrs[.size] as? UInt64,
|
||||
size >= Self.maxSegmentBytes
|
||||
else { return }
|
||||
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
let fm = FileManager.default
|
||||
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
|
||||
try? fm.removeItem(at: oldest)
|
||||
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
|
||||
let src = fileURL.appendingPathExtension("\(i)")
|
||||
let dst = fileURL.appendingPathExtension("\(i + 1)")
|
||||
if fm.fileExists(atPath: src.path) {
|
||||
try? fm.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
|
||||
}
|
||||
|
||||
/// Tail of the active log for the settings dialog's "copy excerpt".
|
||||
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return "" }
|
||||
let slice = data.suffix(maxBytes)
|
||||
return String(decoding: slice, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors during `average` argv construction.
|
||||
public enum AverageArgError: LocalizedError, Equatable, Sendable {
|
||||
case invalidBasename(String)
|
||||
case invalidPassCount(Int)
|
||||
case outputCollidesWithInput
|
||||
case pathOutsideCwd(URL)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBasename(let name):
|
||||
return "Invalid basename for average: \(name)"
|
||||
case .invalidPassCount(let count):
|
||||
return "Average requires at least 2 pass files, got \(count)"
|
||||
case .outputCollidesWithInput:
|
||||
return "Average output filename collides with one of the inputs"
|
||||
case .pathOutsideCwd(let url):
|
||||
return "Pass or output file is outside the working directory: \(url.path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an `average` run.
|
||||
public struct AverageConfig: Sendable, Equatable {
|
||||
public let workingDirectory: URL
|
||||
public let basename: String
|
||||
public let passFiles: [URL]
|
||||
|
||||
public init(
|
||||
workingDirectory: URL,
|
||||
basename: String,
|
||||
passFiles: [URL]
|
||||
) {
|
||||
self.workingDirectory = workingDirectory
|
||||
self.basename = basename
|
||||
self.passFiles = passFiles
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure argv builder for Argyll's `average` tool.
|
||||
public enum AverageArgs {
|
||||
|
||||
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
|
||||
public static func build(config: AverageConfig) throws -> [String] {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
|
||||
guard config.passFiles.count >= 2 else {
|
||||
throw AverageArgError.invalidPassCount(config.passFiles.count)
|
||||
}
|
||||
|
||||
let output = config.workingDirectory
|
||||
.appendingPathComponent("\(cleanBasename).ti3")
|
||||
|
||||
var inputNames: [String] = []
|
||||
for url in config.passFiles {
|
||||
try validate(url, isIn: config.workingDirectory)
|
||||
inputNames.append(url.lastPathComponent)
|
||||
}
|
||||
|
||||
try validate(output, isIn: config.workingDirectory)
|
||||
let outputName = output.lastPathComponent
|
||||
|
||||
guard !inputNames.contains(outputName) else {
|
||||
throw AverageArgError.outputCollidesWithInput
|
||||
}
|
||||
|
||||
return ["-v"] + inputNames + [outputName]
|
||||
}
|
||||
|
||||
private static func validate(_ url: URL, isIn cwd: URL) throws {
|
||||
let cwdPath = cwd.standardizedFileURL.path
|
||||
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
|
||||
guard urlPath == cwdPath else {
|
||||
throw AverageArgError.pathOutsideCwd(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure argv builder for Argyll's `chartread` tool.
|
||||
public enum ChartreadArgs {
|
||||
|
||||
/// Builds `chartread` argv per the Gronod fork protocol.
|
||||
///
|
||||
/// - Always `-v -u`.
|
||||
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
|
||||
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
|
||||
/// - Basename is the last positional argument and is sanitized.
|
||||
public static func build(config: ChartreadConfig) throws -> [String] {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||
|
||||
var args: [String] = ["-v", "-u"]
|
||||
|
||||
if let port = config.selectedPort, port > 1 {
|
||||
args.append(contentsOf: ["-c", "\(port)"])
|
||||
}
|
||||
|
||||
if config.enableLEDs {
|
||||
args.append(contentsOf: ["-Y", "l"])
|
||||
}
|
||||
|
||||
args.append(cleanBasename)
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import Foundation
|
||||
|
||||
/// Discrete states for the `chartread` interaction.
|
||||
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
|
||||
case idle
|
||||
case calibrating
|
||||
case awaitingStrip
|
||||
case reading
|
||||
case allStripsRead
|
||||
case warning
|
||||
case promptContinue
|
||||
case tablePlaceSheet
|
||||
case tableAlign
|
||||
case error
|
||||
case finished
|
||||
}
|
||||
|
||||
/// Extra metadata produced by classifying a single `chartread` stdout line.
|
||||
public struct ChartreadClassifyResult: Sendable, Equatable {
|
||||
public let state: ChartreadState
|
||||
/// Whether this line is an informational "remove last sheet" notice.
|
||||
public let isRemoveSheetNotice: Bool
|
||||
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
|
||||
public let sheetNumber: Int?
|
||||
public let sheetTotal: Int?
|
||||
/// Fiducial patch name from XY "locate patch X with the sight".
|
||||
public let alignmentPatch: String?
|
||||
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
|
||||
public let requestedWarningKey: String?
|
||||
/// Whether the line is a continuation of a multi-line XY prompt.
|
||||
public let isTableContinuation: Bool
|
||||
|
||||
public init(
|
||||
state: ChartreadState,
|
||||
isRemoveSheetNotice: Bool = false,
|
||||
sheetNumber: Int? = nil,
|
||||
sheetTotal: Int? = nil,
|
||||
alignmentPatch: String? = nil,
|
||||
requestedWarningKey: String? = nil,
|
||||
isTableContinuation: Bool = false
|
||||
) {
|
||||
self.state = state
|
||||
self.isRemoveSheetNotice = isRemoveSheetNotice
|
||||
self.sheetNumber = sheetNumber
|
||||
self.sheetTotal = sheetTotal
|
||||
self.alignmentPatch = alignmentPatch
|
||||
self.requestedWarningKey = requestedWarningKey
|
||||
self.isTableContinuation = isTableContinuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure line classifier for `chartread` stdout.
|
||||
///
|
||||
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
|
||||
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
|
||||
public enum ChartreadClassifier {
|
||||
|
||||
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
|
||||
|
||||
public static func classify(
|
||||
line: String,
|
||||
previousState: ChartreadState
|
||||
) -> ChartreadClassifyResult {
|
||||
let text = line.lowercased()
|
||||
|
||||
for matcher in matchers(previousState) {
|
||||
if let result = matcher(text, previousState) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return ChartreadClassifyResult(state: previousState)
|
||||
}
|
||||
|
||||
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
|
||||
[
|
||||
removeSheetNotice,
|
||||
sheetReadOk,
|
||||
locatePatch,
|
||||
placeSheet,
|
||||
continuation(previous),
|
||||
done,
|
||||
warning,
|
||||
calibration,
|
||||
awaitingStrip,
|
||||
reading,
|
||||
error
|
||||
]
|
||||
}
|
||||
|
||||
// 1. "Please remove last sheet from table" — info only.
|
||||
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
|
||||
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
|
||||
}
|
||||
|
||||
// 2. "Sheet N of M read OK".
|
||||
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
|
||||
return ChartreadClassifyResult(
|
||||
state: previous,
|
||||
sheetNumber: match.1,
|
||||
sheetTotal: match.2
|
||||
)
|
||||
}
|
||||
|
||||
// 3. "locate patch X with the sight".
|
||||
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
|
||||
!match.0.isEmpty else { return nil }
|
||||
return ChartreadClassifyResult(
|
||||
state: .tableAlign,
|
||||
alignmentPatch: match.0.uppercased()
|
||||
)
|
||||
}
|
||||
|
||||
// 4. "place sheet N of M" or "remove previous sheet".
|
||||
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
|
||||
return ChartreadClassifyResult(
|
||||
state: .tablePlaceSheet,
|
||||
sheetNumber: match.1,
|
||||
sheetTotal: match.2
|
||||
)
|
||||
}
|
||||
if text.contains("remove previous sheet") || text.contains("place sheet") {
|
||||
return ChartreadClassifyResult(state: .tablePlaceSheet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 5. "hit return to continue" — sticky if already in a table state.
|
||||
private static func continuation(_ previous: ChartreadState) -> Matcher {
|
||||
return { text, _ in
|
||||
guard text.contains("hit return to continue")
|
||||
|| text.contains("hit any key to continue")
|
||||
|| text.contains("hit space to continue")
|
||||
else { return nil }
|
||||
|
||||
if case .tablePlaceSheet = previous {
|
||||
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
|
||||
}
|
||||
if case .tableAlign = previous {
|
||||
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
|
||||
}
|
||||
|
||||
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Done / all read.
|
||||
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let phrases = [
|
||||
"'d' if/when done", "d to finish/save", "all strips/patches read",
|
||||
"all strips read", "all patches read", "done reading",
|
||||
"'d' to save", "press d to", "hit 'd'"
|
||||
]
|
||||
if phrases.contains(where: { text.contains($0) }) {
|
||||
return ChartreadClassifyResult(state: .allStripsRead)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7. Warnings / prompts needing a key.
|
||||
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let warningSignals = [
|
||||
"(warning)", "use it anyway", "seem to have read strip pass",
|
||||
"unexpected response", "seem to have read", "misread",
|
||||
"try again", "do you want to"
|
||||
]
|
||||
guard warningSignals.contains(where: { text.contains($0) }) else { return nil }
|
||||
|
||||
var key: String?
|
||||
if text.contains("(y/n)") || text.contains("'y' or 'n'") {
|
||||
// Default to asking the user; no automatic key.
|
||||
key = nil
|
||||
} else if text.contains("'y'") || text.contains("press y") || text.contains("hit 'y'") {
|
||||
key = "y"
|
||||
} else if text.contains("'n'") || text.contains("press n") || text.contains("hit 'n'") {
|
||||
key = "n"
|
||||
}
|
||||
|
||||
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
|
||||
}
|
||||
|
||||
// 8. Calibration / place reference / white / standard tile.
|
||||
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let lowercased = text.lowercased()
|
||||
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
|
||||
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
|
||||
let hasLocate = lowercased.contains("locate patch")
|
||||
|
||||
guard placeTokens.contains(where: { lowercased.contains($0) }),
|
||||
!hasPlaceSheet,
|
||||
!hasLocate
|
||||
else { return nil }
|
||||
|
||||
if lowercased.contains("hit any key to continue")
|
||||
|| lowercased.contains("hit space to continue")
|
||||
|| lowercased.contains("calibration")
|
||||
|| lowercased.contains("calibrate")
|
||||
|| lowercased.contains("white tile")
|
||||
|| lowercased.contains("standard tile") {
|
||||
return ChartreadClassifyResult(state: .calibrating)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 9. Awaiting strip.
|
||||
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let lowercased = text.lowercased()
|
||||
let phrases = [
|
||||
"hit ... read ... strip", "ready to read", "read ... strip ... key",
|
||||
"hit any key to read", "ready to read strip", "hit a key to read",
|
||||
"press any key to read", "read strip"
|
||||
]
|
||||
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||
}
|
||||
|
||||
// 10. Reading.
|
||||
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let lowercased = text.lowercased()
|
||||
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
|
||||
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||
return ChartreadClassifyResult(state: .reading)
|
||||
}
|
||||
|
||||
// 11. Error.
|
||||
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||
let phrases = ["error", "too fast", "too slow", "misread", "failed to read", "failed"]
|
||||
// Avoid false positives inside harmless words by matching full words where possible.
|
||||
let lower = text
|
||||
guard phrases.contains(where: { phrase in
|
||||
lower.contains(phrase) && !lower.contains("no error")
|
||||
}) else { return nil }
|
||||
|
||||
if lower.contains("misread") || lower.contains("failed to read") || lower.contains("error") {
|
||||
return ChartreadClassifyResult(state: .error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func firstMatch(pattern: String) -> (String, Int, Int)? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
|
||||
else { return nil }
|
||||
|
||||
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
|
||||
let r = match.range(at: i)
|
||||
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
|
||||
return String(self[range])
|
||||
}
|
||||
|
||||
guard let first = groups.first else { return nil }
|
||||
let ints = groups.compactMap { Int($0) }
|
||||
let a = ints.count > 0 ? ints[0] : 0
|
||||
let b = ints.count > 1 ? ints[1] : 0
|
||||
return (first, a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors during `ChartreadArgs` validation.
|
||||
public enum ChartreadArgError: LocalizedError, Equatable {
|
||||
case invalidBasename(String)
|
||||
case invalidPort(Int)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBasename(let name):
|
||||
return "Invalid chart basename: \(name)"
|
||||
case .invalidPort(let port):
|
||||
return "Invalid chartread port: \(port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a `chartread` invocation.
|
||||
public struct ChartreadConfig: Codable, Equatable, Sendable {
|
||||
public var basename: String
|
||||
public var workingDirectory: URL?
|
||||
/// Communication port to pass to `chartread -c`.
|
||||
/// `nil` means omit `-c` (Auto or port 1).
|
||||
public var selectedPort: Int?
|
||||
/// Enable i1Pro 2 visual LEDs (`-Y l`).
|
||||
public var enableLEDs: Bool
|
||||
|
||||
public init(
|
||||
basename: String,
|
||||
workingDirectory: URL? = nil,
|
||||
selectedPort: Int? = nil,
|
||||
enableLEDs: Bool = false,
|
||||
isXY: Bool = false
|
||||
) {
|
||||
self.basename = basename
|
||||
self.workingDirectory = workingDirectory
|
||||
self.selectedPort = selectedPort
|
||||
self.enableLEDs = enableLEDs
|
||||
self.isXY = isXY
|
||||
}
|
||||
|
||||
/// Whether the current config implies an XY-table workflow.
|
||||
/// This is normally supplied by the view model from the selected instrument.
|
||||
public var isXY: Bool = false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
/// A single patch read by `chartread`.
|
||||
public struct ChartreadPatch: Codable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let loc: String
|
||||
public let isPad: Bool
|
||||
public let device: [Double]
|
||||
public let expected: PatchColor?
|
||||
public let measured: PatchColor
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
loc: String,
|
||||
isPad: Bool,
|
||||
device: [Double],
|
||||
expected: PatchColor?,
|
||||
measured: PatchColor
|
||||
) {
|
||||
self.id = id
|
||||
self.loc = loc
|
||||
self.isPad = isPad
|
||||
self.device = device
|
||||
self.expected = expected
|
||||
self.measured = measured
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, loc
|
||||
case isPad = "is_pad"
|
||||
case device, expected, measured
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour payload carried by `expected` or `measured`.
|
||||
public struct PatchColor: Codable, Sendable, Equatable {
|
||||
public let xyz: CIEXYZ?
|
||||
public let lab: CIELab?
|
||||
public let spectral: SpectralData?
|
||||
|
||||
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
|
||||
self.xyz = xyz
|
||||
self.lab = lab
|
||||
self.spectral = spectral
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case xyz = "XYZ"
|
||||
case lab = "Lab"
|
||||
case spectral = "spectral"
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIEXYZ: Codable, Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.x = try container.decode(Double.self)
|
||||
self.y = try container.decode(Double.self)
|
||||
self.z = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(x: Double, y: Double, z: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(x)
|
||||
try container.encode(y)
|
||||
try container.encode(z)
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIELab: Codable, Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
self.l = try container.decode(Double.self)
|
||||
self.a = try container.decode(Double.self)
|
||||
self.b = try container.decode(Double.self)
|
||||
}
|
||||
|
||||
public init(l: Double, a: Double, b: Double) {
|
||||
self.l = l
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(l)
|
||||
try container.encode(a)
|
||||
try container.encode(b)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SpectralData: Codable, Sendable, Equatable {
|
||||
public let bands: Int
|
||||
public let startNM: Double
|
||||
public let endNM: Double
|
||||
public let norm: Double
|
||||
public let values: [Double]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bands
|
||||
case startNM = "start_nm"
|
||||
case endNM = "end_nm"
|
||||
case norm
|
||||
case values
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete row emitted by `chartread -u`.
|
||||
public struct ChartreadRow: Codable, Sendable, Equatable {
|
||||
public let event: String
|
||||
public let rowId: String
|
||||
public let rowIndex: Int
|
||||
public let totalRows: Int
|
||||
public let patchCount: Int
|
||||
public let patches: [ChartreadPatch]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case event
|
||||
case rowId = "row_id"
|
||||
case rowIndex = "row_index"
|
||||
case totalRows = "total_rows"
|
||||
case patchCount = "patch_count"
|
||||
case patches
|
||||
}
|
||||
|
||||
public var isFinalRow: Bool {
|
||||
rowIndex + 1 >= totalRows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
/// Result of evaluating one measured patch.
|
||||
public struct SwatchEvaluation: Sendable, Equatable {
|
||||
public let intended: DisplayRGB
|
||||
public let measured: DisplayRGB
|
||||
public let deltaE: Double?
|
||||
public let classification: SwatchClassification
|
||||
|
||||
public init(
|
||||
intended: DisplayRGB,
|
||||
measured: DisplayRGB,
|
||||
deltaE: Double?,
|
||||
classification: SwatchClassification
|
||||
) {
|
||||
self.intended = intended
|
||||
self.measured = measured
|
||||
self.deltaE = deltaE
|
||||
self.classification = classification
|
||||
}
|
||||
}
|
||||
|
||||
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
|
||||
case good
|
||||
case warning
|
||||
case bad
|
||||
}
|
||||
|
||||
/// CIEDE2000 ΔE₀₀ between two D50 Lab values.
|
||||
public enum ColorDifference {
|
||||
|
||||
/// Compute ΔE₀₀ using the full CIEDE2000 formula.
|
||||
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
|
||||
let kL: Double = 1
|
||||
let kC: Double = 1
|
||||
let kH: Double = 1
|
||||
|
||||
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
|
||||
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
|
||||
|
||||
let cBar = (c1 + c2) / 2.0
|
||||
let cBar7 = pow(cBar, 7)
|
||||
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
|
||||
|
||||
let a1p = (1 + g) * lab1.a
|
||||
let a2p = (1 + g) * lab2.a
|
||||
|
||||
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
|
||||
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
|
||||
|
||||
let h1p = atan2ToDegrees(lab1.b, a1p)
|
||||
let h2p = atan2ToDegrees(lab2.b, a2p)
|
||||
|
||||
let deltaLp = lab2.l - lab1.l
|
||||
let deltaCp = c2p - c1p
|
||||
|
||||
var deltaHp: Double = 0
|
||||
if c1p * c2p == 0 {
|
||||
deltaHp = 0
|
||||
} else {
|
||||
let diff = h2p - h1p
|
||||
if abs(diff) <= 180 {
|
||||
deltaHp = diff
|
||||
} else if diff > 180 {
|
||||
deltaHp = diff - 360
|
||||
} else {
|
||||
deltaHp = diff + 360
|
||||
}
|
||||
}
|
||||
|
||||
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
|
||||
|
||||
let lBarp = (lab1.l + lab2.l) / 2.0
|
||||
let cBarp = (c1p + c2p) / 2.0
|
||||
|
||||
var hBarp: Double
|
||||
if c1p * c2p == 0 {
|
||||
hBarp = h1p + h2p
|
||||
} else {
|
||||
if abs(h1p - h2p) <= 180 {
|
||||
hBarp = (h1p + h2p) / 2.0
|
||||
} else if h1p + h2p < 360 {
|
||||
hBarp = (h1p + h2p + 360) / 2.0
|
||||
} else {
|
||||
hBarp = (h1p + h2p - 360) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
let t = 1
|
||||
- 0.17 * cos(deg2rad(hBarp - 30))
|
||||
+ 0.24 * cos(deg2rad(2 * hBarp))
|
||||
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
|
||||
- 0.20 * cos(deg2rad(4 * hBarp - 63))
|
||||
|
||||
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
|
||||
let cBarp7 = pow(cBarp, 7)
|
||||
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
|
||||
|
||||
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
|
||||
let sc = 1 + 0.045 * cBarp
|
||||
let sh = 1 + 0.015 * cBarp * t
|
||||
|
||||
let rt = -sin(deg2rad(2 * dTheta)) * rc
|
||||
|
||||
let lTerm = deltaLp / (kL * sl)
|
||||
let cTerm = deltaCp / (kC * sc)
|
||||
let hTerm = deltaHp2 / (kH * sh)
|
||||
|
||||
return sqrt(
|
||||
lTerm * lTerm
|
||||
+ cTerm * cTerm
|
||||
+ hTerm * hTerm
|
||||
+ rt * cTerm * hTerm
|
||||
)
|
||||
}
|
||||
|
||||
/// Classify a ΔE value against user thresholds.
|
||||
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
|
||||
if deltaE < goodMax { return .good }
|
||||
if deltaE < warningMax { return .warning }
|
||||
return .bad
|
||||
}
|
||||
|
||||
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
|
||||
public static func evaluate(
|
||||
patch: ChartreadPatch,
|
||||
goodMax: Double,
|
||||
warningMax: Double
|
||||
) -> SwatchEvaluation? {
|
||||
guard let measured = resolveLab(patch.measured) else { return nil }
|
||||
|
||||
let measuredRGB = LabColorMath.labToSRGB(measured)
|
||||
|
||||
if let expectedColor = patch.expected,
|
||||
let expectedLab = resolveLab(expectedColor) {
|
||||
let de = deltaE00(expectedLab, measured)
|
||||
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
|
||||
return SwatchEvaluation(
|
||||
intended: intendedRGB,
|
||||
measured: measuredRGB,
|
||||
deltaE: de,
|
||||
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
|
||||
)
|
||||
} else {
|
||||
// No reference: still render measured colour, no ΔE.
|
||||
return SwatchEvaluation(
|
||||
intended: measuredRGB,
|
||||
measured: measuredRGB,
|
||||
deltaE: nil,
|
||||
classification: .good
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||
if let lab = color.lab {
|
||||
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
||||
}
|
||||
guard let xyz = color.xyz else { return nil }
|
||||
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
||||
}
|
||||
|
||||
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||
let radians = atan2(y, x)
|
||||
var degrees = radians * 180.0 / .pi
|
||||
if degrees < 0 { degrees += 360 }
|
||||
return degrees
|
||||
}
|
||||
|
||||
private static func deg2rad(_ degrees: Double) -> Double {
|
||||
degrees * .pi / 180.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// A device discovered by the Argyll `instlist` fork.
|
||||
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
|
||||
public let port: Int
|
||||
public let name: String
|
||||
public let type: String
|
||||
|
||||
public init(port: Int, name: String, type: String) {
|
||||
self.port = port
|
||||
self.name = name
|
||||
self.type = type
|
||||
}
|
||||
|
||||
public var id: Int { port }
|
||||
|
||||
/// XY tables are identified by name or type matching the fork pattern.
|
||||
public var isXY: Bool {
|
||||
let combined = "\(name) \(type)".lowercased()
|
||||
let pattern = #"/spectro\s?scan|i1io/"#
|
||||
return combined.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
/// Human-readable label shown in the picker.
|
||||
public var displayName: String {
|
||||
let xyTag = isXY ? " · XY Table" : ""
|
||||
return "\(name) [\(type)]\(xyTag)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The user’s choice for a chartread session.
|
||||
public enum InstrumentSelection: Sendable, Equatable {
|
||||
/// Auto / first available port — `chartread` omits `-c`.
|
||||
case auto
|
||||
/// A concrete instrument.
|
||||
case device(InstrumentDevice)
|
||||
|
||||
/// The value to pass to `chartread -c`.
|
||||
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
|
||||
public var chartreadPort: Int? {
|
||||
switch self {
|
||||
case .auto:
|
||||
return nil
|
||||
case .device(let device):
|
||||
return device.port == 1 ? nil : device.port
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the current selection implies an XY table workflow.
|
||||
public var isXY: Bool {
|
||||
switch self {
|
||||
case .auto:
|
||||
return false
|
||||
case .device(let device):
|
||||
return device.isXY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from `instlist` output parsing.
|
||||
public enum InstrumentParserError: Error, Sendable, Equatable {
|
||||
case malformedJSON
|
||||
case missingDevices
|
||||
case invalidPort
|
||||
}
|
||||
|
||||
/// Parses the Argyll `instlist` stdout document.
|
||||
///
|
||||
/// Fork `instlist` emits pretty-printed JSON with the shape
|
||||
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
|
||||
/// If JSON decoding fails, a constrained regex fallback is used.
|
||||
/// Only lines accepted by the fallback must also match known instrument tokens.
|
||||
public enum InstrumentParser {
|
||||
|
||||
/// Known instrument tokens used by the regex fallback.
|
||||
public static let knownInstrumentPattern =
|
||||
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
|
||||
|
||||
/// Parse the complete `instlist` output.
|
||||
public static func parse(_ output: String) throws -> [InstrumentDevice] {
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let decoded = try? decodeJSON(data) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
let regex = try? NSRegularExpression(
|
||||
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
|
||||
options: [.caseInsensitive, .anchorsMatchLines]
|
||||
)
|
||||
var devices: [InstrumentDevice] = []
|
||||
let range = NSRange(trimmed.startIndex..., in: trimmed)
|
||||
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
|
||||
|
||||
for match in matches {
|
||||
guard let portString = substring(trimmed, range: match.range(at: 1)),
|
||||
let port = Int(portString), port > 0 else { continue }
|
||||
|
||||
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
let combined = "\(name) \(type)".lowercased()
|
||||
guard combined.range(of: knownInstrumentPattern,
|
||||
options: [.regularExpression, .caseInsensitive]) != nil,
|
||||
!name.isEmpty else { continue }
|
||||
|
||||
devices.append(InstrumentDevice(port: port, name: name, type: type))
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
|
||||
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
|
||||
return output.devices
|
||||
}
|
||||
|
||||
private static func substring(_ source: String, range: NSRange) -> String? {
|
||||
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
|
||||
return String(source[r])
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstlistOutput: Decodable {
|
||||
let event: String
|
||||
let devices: [InstrumentDevice]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
|
||||
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||
public struct XYZColor: Sendable, Equatable {
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let z: Double
|
||||
|
||||
public init(x: Double, y: Double, z: Double) {
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.z = z
|
||||
}
|
||||
}
|
||||
|
||||
/// CIELab value (D50).
|
||||
public struct LabColor: Sendable, Equatable {
|
||||
public let l: Double
|
||||
public let a: Double
|
||||
public let b: Double
|
||||
|
||||
public init(l: Double, a: Double, b: Double) {
|
||||
self.l = l
|
||||
self.a = a
|
||||
self.b = b
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB colour in 0–1 display space.
|
||||
public struct DisplayRGB: Sendable, Equatable {
|
||||
public let r: Double
|
||||
public let g: Double
|
||||
public let b: Double
|
||||
|
||||
public init(r: Double, g: Double, b: Double) {
|
||||
self.r = r
|
||||
self.g = g
|
||||
self.b = b
|
||||
}
|
||||
|
||||
public var clamped: DisplayRGB {
|
||||
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour-space conversions used by the swatch grid.
|
||||
///
|
||||
/// All numeric paths are deterministic and avoid platform colour-management APIs.
|
||||
public enum LabColorMath {
|
||||
|
||||
// Reference white for D50 (0–100 scale).
|
||||
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
|
||||
|
||||
// Reference white for D65 (0–100 scale).
|
||||
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
|
||||
|
||||
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
|
||||
static let bradford = [
|
||||
[ 0.8951, 0.2664, -0.1614],
|
||||
[-0.7502, 1.7135, 0.0367],
|
||||
[ 0.0389, -0.0685, 1.0296]
|
||||
]
|
||||
|
||||
static let bradfordInv = [
|
||||
[ 0.9869929, -0.1470543, 0.1599627],
|
||||
[ 0.4323053, 0.5183603, 0.0492912],
|
||||
[-0.0085287, 0.0400428, 0.9684866]
|
||||
]
|
||||
|
||||
// sRGB D65 matrix (XYZ -> linear sRGB, using 0–100 inputs).
|
||||
static let srgbMatrix = [
|
||||
[ 3.2406, -1.5372, -0.4986],
|
||||
[-0.9689, 1.8758, 0.0415],
|
||||
[ 0.0557, -0.2040, 1.0570]
|
||||
]
|
||||
|
||||
/// Convert XYZ (0–100) to CIELab D50.
|
||||
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
|
||||
let f: (Double) -> Double = { t in
|
||||
let delta = 6.0 / 29.0
|
||||
if t > delta * delta * delta {
|
||||
return pow(t, 1.0 / 3.0)
|
||||
} else {
|
||||
return t / (3 * delta * delta) + 4.0 / 29.0
|
||||
}
|
||||
}
|
||||
|
||||
let x = f(xyz.x / d50White.X)
|
||||
let y = f(xyz.y / d50White.Y)
|
||||
let zr = f(xyz.z / d50White.Z)
|
||||
|
||||
return LabColor(
|
||||
l: 116.0 * y - 16.0,
|
||||
a: 500.0 * (x - y),
|
||||
b: 200.0 * (y - zr)
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert CIELab D50 to XYZ (0–100).
|
||||
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
|
||||
let finv: (Double) -> Double = { t in
|
||||
let delta = 6.0 / 29.0
|
||||
if t > delta {
|
||||
return t * t * t
|
||||
} else {
|
||||
return 3 * delta * delta * (t - 4.0 / 29.0)
|
||||
}
|
||||
}
|
||||
|
||||
let yr = (lab.l + 16.0) / 116.0
|
||||
let xr = yr + lab.a / 500.0
|
||||
let zr = yr - lab.b / 200.0
|
||||
|
||||
return XYZColor(
|
||||
x: finv(xr) * d50White.X,
|
||||
y: finv(yr) * d50White.Y,
|
||||
z: finv(zr) * d50White.Z
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
|
||||
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
|
||||
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
|
||||
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
|
||||
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
|
||||
|
||||
let scaled = [
|
||||
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||
source[2] * (dstWhite[2] / srcWhite[2])
|
||||
]
|
||||
|
||||
return XYZColor(
|
||||
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
|
||||
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
|
||||
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert XYZ D65 (0–100) to linear sRGB, apply gamma, and clamp.
|
||||
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
|
||||
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
|
||||
// The input is 0–100, so scale by 100 first.
|
||||
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
|
||||
let linear = matrixMultiply(srgbMatrix, scaled)
|
||||
|
||||
func gamma(_ c: Double) -> Double {
|
||||
if c <= 0.0031308 {
|
||||
return 12.92 * c
|
||||
} else {
|
||||
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
return DisplayRGB(
|
||||
r: gamma(linear[0]),
|
||||
g: gamma(linear[1]),
|
||||
b: gamma(linear[2])
|
||||
).clamped
|
||||
}
|
||||
|
||||
/// Complete D50 Lab -> display sRGB conversion.
|
||||
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
|
||||
let xyz50 = labToXYZ(lab)
|
||||
let xyz65 = adaptD50ToD65(xyz50)
|
||||
return xyzToSRGB(xyz65)
|
||||
}
|
||||
|
||||
/// Convert an Argyll XYZ array (0–100) to Lab and then to sRGB.
|
||||
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
|
||||
guard xyz.count >= 3 else { return nil }
|
||||
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
|
||||
}
|
||||
|
||||
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||
var result = [Double](repeating: 0, count: m.count)
|
||||
for i in 0..<m.count {
|
||||
for j in 0..<v.count {
|
||||
result[i] += m[i][j] * v[j]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors from pass-snapshot, promote, and discovery operations.
|
||||
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
|
||||
case invalidBasename(String)
|
||||
case canonicalMissing(URL)
|
||||
case snapshotFailed(String)
|
||||
case promoteFailed(String)
|
||||
case noPassFiles
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBasename(let name):
|
||||
return "Invalid measurement basename: \(name)"
|
||||
case .canonicalMissing(let url):
|
||||
return "Canonical .ti3 not found: \(url.path)"
|
||||
case .snapshotFailed(let reason):
|
||||
return "Snapshot failed: \(reason)"
|
||||
case .promoteFailed(let reason):
|
||||
return "Promote failed: \(reason)"
|
||||
case .noPassFiles:
|
||||
return "No pass .ti3 snapshots are available."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem helpers for multi-pass measurement artefacts.
|
||||
///
|
||||
/// Pass snapshots use 1-based numbering and are tracked as
|
||||
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
|
||||
/// Finish / Average (docs/24 #109, #110).
|
||||
public enum MeasurementArtefacts {
|
||||
|
||||
/// Find all existing pass snapshots in `cwd`, sorted numerically.
|
||||
public static func passSnapshots(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> [URL] {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: cwd,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return [] }
|
||||
|
||||
let prefix = "\(basename)_pass"
|
||||
let suffix = "ti3"
|
||||
|
||||
let passes: [(Int, URL)] = entries.compactMap { url in
|
||||
let name = url.lastPathComponent
|
||||
guard url.pathExtension.lowercased() == suffix,
|
||||
name.hasPrefix(prefix)
|
||||
else { return nil }
|
||||
|
||||
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||
guard let number = Int(numberPart), number > 0 else { return nil }
|
||||
return (number, url)
|
||||
}
|
||||
|
||||
return passes
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1 }
|
||||
}
|
||||
|
||||
/// The next 1-based pass number.
|
||||
public static func nextPassNumber(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) -> Int {
|
||||
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
|
||||
guard let last = existing.last else { return 1 }
|
||||
let name = last.lastPathComponent
|
||||
let prefix = "\(basename)_pass"
|
||||
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||
return (Int(numberPart) ?? 0) + 1
|
||||
}
|
||||
|
||||
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
|
||||
///
|
||||
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
|
||||
public static func snapshotPass(
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> URL {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||
guard fileManager.fileExists(atPath: canonical.path) else {
|
||||
throw MeasurementArtefactError.canonicalMissing(canonical)
|
||||
}
|
||||
|
||||
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
|
||||
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
|
||||
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
|
||||
|
||||
if fileManager.fileExists(atPath: temp.path) {
|
||||
try? fileManager.removeItem(at: temp)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.copyItem(at: canonical, to: temp)
|
||||
} catch {
|
||||
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: pass.path) {
|
||||
try? fileManager.removeItem(at: pass)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.moveItem(at: temp, to: pass)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: temp)
|
||||
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.removeItem(at: canonical)
|
||||
} catch {
|
||||
// The pass file is durable; canonical removal failure is logged but not fatal.
|
||||
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return pass
|
||||
}
|
||||
|
||||
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
|
||||
public static func promotePass(
|
||||
pass: URL,
|
||||
basename: String,
|
||||
cwd: URL,
|
||||
fileManager: FileManager = .default
|
||||
) throws -> URL {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
|
||||
|
||||
guard fileManager.fileExists(atPath: pass.path) else {
|
||||
throw MeasurementArtefactError.noPassFiles
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: temp.path) {
|
||||
try? fileManager.removeItem(at: temp)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.copyItem(at: pass, to: temp)
|
||||
} catch {
|
||||
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: canonical.path) {
|
||||
try? fileManager.removeItem(at: canonical)
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.moveItem(at: temp, to: canonical)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: temp)
|
||||
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return canonical
|
||||
}
|
||||
|
||||
/// Canonical `<basename>.ti3` URL, regardless of existence.
|
||||
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
|
||||
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||
return cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Well-known filesystem locations for the ICCery host process.
|
||||
///
|
||||
/// macOS paths (docs/02 §Persistence):
|
||||
/// - App data: `~/Library/Application Support/<bundle-id>/`
|
||||
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
|
||||
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
|
||||
public enum AppPaths {
|
||||
|
||||
/// `com.gronod.iccery2` — read from the main bundle so tests can override.
|
||||
public static var bundleIdentifier: String {
|
||||
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
|
||||
}
|
||||
|
||||
/// `~/Library/Application Support/com.gronod.iccery2`
|
||||
///
|
||||
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
|
||||
/// against an isolated root and never touch the developer's state.
|
||||
public static var appDataDir: URL {
|
||||
#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 {
|
||||
#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)
|
||||
}
|
||||
|
||||
/// `<app>/Contents/Resources/Argyll` — bundled sidecar root.
|
||||
public static var bundledArgyllDir: URL {
|
||||
Bundle.main.resourceURL?
|
||||
.appendingPathComponent("Argyll", isDirectory: true)
|
||||
?? URL(fileURLWithPath: "/nonexistent")
|
||||
}
|
||||
|
||||
/// Creates the app data and log directories if missing.
|
||||
@discardableResult
|
||||
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
|
||||
return (appDataDir, logDir)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Accumulates stdout lines into a complete JSON document.
|
||||
///
|
||||
/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest)
|
||||
/// emit pretty-printed multi-line JSON on stdout. Individual lines are
|
||||
/// *not* valid JSON — only the whole block is — so callers route stdout
|
||||
/// lines here and get `Data` back once the buffer parses.
|
||||
///
|
||||
/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager
|
||||
/// diverts them to `jsonRow` events first.
|
||||
public struct JSONAccumulator: Sendable {
|
||||
private var buffer = Data()
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Appends one stdout line. Returns the complete document bytes when
|
||||
/// the accumulated buffer forms valid JSON, otherwise `nil`.
|
||||
public mutating func feed(line: String) -> Data? {
|
||||
buffer.append(Data(line.utf8))
|
||||
buffer.append(0x0A)
|
||||
return tryParse()
|
||||
}
|
||||
|
||||
/// Attempts to decode the accumulated buffer; clears it on success.
|
||||
public mutating func decode<T: Decodable>(_ type: T.Type) -> T? {
|
||||
guard let data = tryParse() else { return nil }
|
||||
return try? JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
/// Raw buffer when it parses, `nil` while still incomplete.
|
||||
public var completeData: Data? {
|
||||
var copy = self
|
||||
return copy.tryParse()
|
||||
}
|
||||
|
||||
public mutating func reset() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
public var isEmpty: Bool { buffer.isEmpty }
|
||||
|
||||
private mutating func tryParse() -> Data? {
|
||||
// Cheap gate: JSON documents start with { or [.
|
||||
guard let first = buffer.first(where: { !$0.isJSONWhitespace }),
|
||||
first == UInt8(ascii: "{") || first == UInt8(ascii: "[")
|
||||
else { return nil }
|
||||
guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil }
|
||||
let out = buffer
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private extension UInt8 {
|
||||
var isJSONWhitespace: Bool {
|
||||
self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Events on the process bus — the v2 equivalent of the v1 Tauri events
|
||||
/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus).
|
||||
public enum ProcessEvent: Sendable, Equatable {
|
||||
/// Non-JSON stdout line. (`process:stdout`)
|
||||
case stdout(id: String, line: String)
|
||||
/// stderr line. (`process:stderr`)
|
||||
case stderr(id: String, line: String)
|
||||
/// Child exited; 0 = success. (`process:exit`)
|
||||
case exit(id: String, code: Int32)
|
||||
/// Spawn failure. (`process:error`)
|
||||
case error(id: String, message: String)
|
||||
/// Stdout line began with `ROW_COLORS_JSON: ` — prefix stripped,
|
||||
/// payload is the remaining raw bytes. (`process:json_row`)
|
||||
case jsonRow(id: String, payload: Data)
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .stdout(let id, _), .stderr(let id, _), .exit(let id, _),
|
||||
.error(let id, _), .jsonRow(let id, _):
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ProcessError: Error, Equatable, Sendable {
|
||||
/// A child with this id is still running (#116).
|
||||
case duplicateID(String)
|
||||
/// No child registered under this id.
|
||||
case unknownID(String)
|
||||
/// Process refused to launch.
|
||||
case spawnFailed(String)
|
||||
/// 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// Deterministic process ids (docs/02 §Event bus). Listeners must always
|
||||
/// filter events on `id` — historical bug #56 was an id mismatch.
|
||||
public enum ProcessID {
|
||||
public static let instlist = "instlist"
|
||||
|
||||
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
|
||||
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
|
||||
public static func chartread(_ basename: String) -> String { "chartread_\(basename)" }
|
||||
public static func average(_ basename: String) -> String { "average_\(basename)" }
|
||||
public static func colprof(_ basename: String) -> String { "colprof_\(basename)" }
|
||||
public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" }
|
||||
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)" }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Incremental byte→line decoder for process pipes.
|
||||
///
|
||||
/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never
|
||||
/// appear inside a multi-byte UTF-8 sequence (continuation bytes are
|
||||
/// ≥ 0x80), so splitting bytes at `\n` is always scalar-safe; each line
|
||||
/// is then decoded with a lossy fallback for non-UTF-8 output.
|
||||
public struct ProcessLineDecoder: Sendable {
|
||||
public private(set) var pending = Data()
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Feeds a chunk; returns every complete line found (without `\n`).
|
||||
public mutating func feed(_ chunk: Data) -> [String] {
|
||||
guard !chunk.isEmpty else { return [] }
|
||||
pending.append(chunk)
|
||||
var lines: [String] = []
|
||||
while let nl = pending.firstIndex(of: 0x0A) {
|
||||
var slice = pending.prefix(upTo: nl)
|
||||
pending = pending.suffix(from: pending.index(after: nl))
|
||||
// Tolerate CRLF output.
|
||||
if slice.last == 0x0D { slice = slice.dropLast() }
|
||||
lines.append(Self.decode(slice))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/// Flushes any unterminated remainder at EOF. Returns `nil` when empty.
|
||||
public mutating func finish() -> String? {
|
||||
guard !pending.isEmpty else { return nil }
|
||||
var rest = pending
|
||||
pending.removeAll(keepingCapacity: false)
|
||||
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||
return rest.isEmpty ? nil : Self.decode(rest)
|
||||
}
|
||||
|
||||
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||
String(decoding: bytes, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import Foundation
|
||||
|
||||
/// 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
|
||||
public let exitCode: Int32
|
||||
}
|
||||
|
||||
/// Spawn / stdin / kill / event bus for Argyll sidecar children
|
||||
/// (docs/02 §Event bus, docs/03 §Process manager).
|
||||
///
|
||||
/// Invariants:
|
||||
/// - Duplicate `id` while a child runs is rejected (#116).
|
||||
/// - The stdin handle lives in its own map, independent of wait, so
|
||||
/// `sendStdin` never blocks on process exit (#84).
|
||||
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
|
||||
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
|
||||
/// with the prefix stripped; all other stdout is `stdout` events.
|
||||
/// - `exit` is emitted exactly once per child, and only after both
|
||||
/// output pipes reach EOF — so no buffered output is lost on fast
|
||||
/// exits or kills.
|
||||
/// - `kill` drops the stdin handle so writers fail fast.
|
||||
public actor ProcessManager {
|
||||
|
||||
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
|
||||
|
||||
public static let shared = ProcessManager()
|
||||
|
||||
// MARK: - Event bus (multicast)
|
||||
|
||||
/// 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> {
|
||||
let box = subscriberBox
|
||||
let token = UUID()
|
||||
return AsyncStream { continuation in
|
||||
box.add(continuation, token: token)
|
||||
continuation.onTermination = { _ in box.remove(token) }
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func emit(_ event: ProcessEvent) {
|
||||
subscriberBox.yield(event)
|
||||
}
|
||||
|
||||
// MARK: - Child registry
|
||||
|
||||
private struct RunningChild {
|
||||
let process: Process
|
||||
/// stdin lives in its own slot, independent of process wait (#84).
|
||||
var stdin: FileHandle?
|
||||
var stdoutDecoder: ProcessLineDecoder
|
||||
var stderrDecoder: ProcessLineDecoder
|
||||
var stdoutEOF = false
|
||||
var stderrEOF = false
|
||||
/// Set by the termination handler; `exit` is emitted once both
|
||||
/// pipes have also reached EOF.
|
||||
var pendingExitCode: Int32?
|
||||
var finalized = false
|
||||
}
|
||||
|
||||
private var children: [String: RunningChild] = [:]
|
||||
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||
private var captured: [String: Process] = [:]
|
||||
|
||||
/// Ids of currently-running children.
|
||||
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||
|
||||
public func isRunning(_ id: String) -> Bool {
|
||||
children[id] != nil || captured[id] != nil
|
||||
}
|
||||
|
||||
// MARK: - Spawn (streaming)
|
||||
|
||||
/// Spawns a streaming child. Returns after spawn; callers wait for
|
||||
/// `exit(id:)` events — never assume the return means the tool
|
||||
/// finished (docs/03).
|
||||
public func runStreaming(
|
||||
id: String,
|
||||
binary: URL,
|
||||
arguments: [String],
|
||||
workingDirectory: URL? = nil,
|
||||
environment: [String: String] = [:]
|
||||
) throws {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdinPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardInput = stdinPipe
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
)
|
||||
|
||||
children[id] = RunningChild(
|
||||
process: process,
|
||||
stdin: stdinPipe.fileHandleForWriting,
|
||||
stdoutDecoder: ProcessLineDecoder(),
|
||||
stderrDecoder: ProcessLineDecoder()
|
||||
)
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
children.removeValue(forKey: id)
|
||||
emit(.error(id: id, message: error.localizedDescription))
|
||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let stdoutHandle = stdoutPipe.fileHandleForReading
|
||||
let stderrHandle = stderrPipe.fileHandleForReading
|
||||
stdoutHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard let self else { return }
|
||||
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
|
||||
}
|
||||
stderrHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard let self else { return }
|
||||
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] proc in
|
||||
guard let self else { return }
|
||||
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spawn (captured)
|
||||
|
||||
/// 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) and by
|
||||
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
|
||||
public func runCaptured(
|
||||
id: String,
|
||||
binary: URL,
|
||||
arguments: [String],
|
||||
workingDirectory: URL? = nil,
|
||||
environment: [String: String] = [:]
|
||||
) async throws -> CapturedResult {
|
||||
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
|
||||
|
||||
let process = Process()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = workingDirectory
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
process.environment = childEnvironment(extra: environment)
|
||||
|
||||
AppLogger(category: "process").debug(
|
||||
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
|
||||
)
|
||||
|
||||
// Register before run() so a concurrent duplicate spawn fails.
|
||||
captured[id] = process
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
captured.removeValue(forKey: id)
|
||||
emit(.error(id: id, message: error.localizedDescription))
|
||||
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
async let outData = Task.detached {
|
||||
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
async let errData = Task.detached {
|
||||
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
}.value
|
||||
|
||||
let code = await withCheckedContinuation { continuation in
|
||||
process.terminationHandler = { proc in
|
||||
continuation.resume(returning: proc.terminationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
let (out, err) = await (outData, errData)
|
||||
// If kill() already reaped this child, its exit event went out.
|
||||
if captured.removeValue(forKey: id) != nil {
|
||||
emit(.exit(id: id, code: code))
|
||||
}
|
||||
|
||||
return CapturedResult(
|
||||
stdout: String(decoding: out, as: UTF8.self),
|
||||
stderr: String(decoding: err, as: UTF8.self),
|
||||
exitCode: code
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - stdin
|
||||
|
||||
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
|
||||
/// and flushes (docs/03 §stdin protocol).
|
||||
public func sendStdin(id: String, bytes: Data) throws {
|
||||
guard let child = children[id] else { throw ProcessError.unknownID(id) }
|
||||
guard let handle = child.stdin else {
|
||||
throw ProcessError.stdinFailed("stdin closed for \(id)")
|
||||
}
|
||||
do {
|
||||
try handle.write(contentsOf: bytes)
|
||||
} catch {
|
||||
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
public func sendStdin(id: String, text: String) throws {
|
||||
try sendStdin(id: id, bytes: Data(text.utf8))
|
||||
}
|
||||
|
||||
// MARK: - Kill
|
||||
|
||||
/// Terminates a child. The `exit` event still fires exactly once.
|
||||
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
|
||||
public func kill(id: String) {
|
||||
if var child = children[id] {
|
||||
try? child.stdin?.close()
|
||||
child.stdin = nil
|
||||
children[id] = child
|
||||
if child.process.isRunning {
|
||||
child.process.terminate()
|
||||
} else {
|
||||
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if let process = captured[id] {
|
||||
if process.isRunning { process.terminate() }
|
||||
if captured.removeValue(forKey: id) != nil {
|
||||
emit(.exit(id: id, code: process.terminationStatus))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminates every running child; returns how many were signaled
|
||||
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||
@discardableResult
|
||||
public func killAll() -> Int {
|
||||
let ids = Array(children.keys) + Array(captured.keys)
|
||||
for id in ids { kill(id: id) }
|
||||
return ids.count
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func childEnvironment(extra: [String: String]) -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["ARGYLL_NOT_INTERACTIVE"] = "1"
|
||||
for (key, value) in extra { env[key] = value }
|
||||
return env
|
||||
}
|
||||
|
||||
private func ingestOutput(
|
||||
_ data: Data,
|
||||
id: String,
|
||||
isStderr: Bool,
|
||||
handle: FileHandle
|
||||
) {
|
||||
guard var child = children[id] else { return }
|
||||
|
||||
if data.isEmpty {
|
||||
// EOF on this pipe.
|
||||
handle.readabilityHandler = nil
|
||||
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
|
||||
children[id] = child
|
||||
maybeFinalize(id: id)
|
||||
return
|
||||
}
|
||||
|
||||
let lines: [String] = isStderr
|
||||
? child.stderrDecoder.feed(data)
|
||||
: child.stdoutDecoder.feed(data)
|
||||
children[id] = child
|
||||
|
||||
let log = AppLogger(category: "subprocess")
|
||||
for line in lines {
|
||||
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
|
||||
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||
emit(.jsonRow(id: id, payload: payload))
|
||||
} else if isStderr {
|
||||
log.warn("[\(id)] \(line)")
|
||||
emit(.stderr(id: id, line: line))
|
||||
} else {
|
||||
log.info("[\(id)] \(line)")
|
||||
emit(.stdout(id: id, line: line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func didTerminate(id: String, code: Int32) {
|
||||
guard var child = children[id], !child.finalized else { return }
|
||||
child.pendingExitCode = code
|
||||
try? child.stdin?.close()
|
||||
child.stdin = nil
|
||||
children[id] = child
|
||||
maybeFinalize(id: id)
|
||||
}
|
||||
|
||||
/// Emits `exit` once the child has terminated *and* both pipes have
|
||||
/// drained to EOF, so no buffered output is lost.
|
||||
private func maybeFinalize(id: String) {
|
||||
guard var child = children[id],
|
||||
let code = child.pendingExitCode,
|
||||
child.stdoutEOF, child.stderrEOF,
|
||||
!child.finalized
|
||||
else { return }
|
||||
child.finalized = true
|
||||
children.removeValue(forKey: id)
|
||||
|
||||
// Flush unterminated tail lines.
|
||||
if var decoder = Optional(child.stdoutDecoder),
|
||||
let tail = decoder.finish() {
|
||||
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
|
||||
} else {
|
||||
emit(.stdout(id: id, line: tail))
|
||||
}
|
||||
}
|
||||
if var decoder = Optional(child.stderrDecoder),
|
||||
let tail = decoder.finish() {
|
||||
emit(.stderr(id: id, line: tail))
|
||||
}
|
||||
emit(.exit(id: id, code: code))
|
||||
}
|
||||
}
|
||||
@@ -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,45 @@
|
||||
import Foundation
|
||||
|
||||
/// The five wizard stages plus Stage 0 (calibration), matching the v1
|
||||
/// `data-stage` contract in docs/21. Stepper buttons 1–5 map to
|
||||
/// `.generate` … `.verifyInstall`; `.calibrate` lives outside the stepper.
|
||||
public enum WizardStage: Int, CaseIterable, Sendable, Codable {
|
||||
case calibrate = 0
|
||||
case generate = 1
|
||||
case layOutPrint = 2
|
||||
case measure = 3
|
||||
case buildProfile = 4
|
||||
case verifyInstall = 5
|
||||
|
||||
/// Sidebar stepper position (1–5); `nil` for the out-of-band calibrate stage.
|
||||
public var stepperIndex: Int? {
|
||||
self == .calibrate ? nil : rawValue
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .calibrate: return "Printer Calibration"
|
||||
case .generate: return "Generate Target"
|
||||
case .layOutPrint: return "Lay Out & Print"
|
||||
case .measure: return "Measure Chart"
|
||||
case .buildProfile: return "Build Profile"
|
||||
case .verifyInstall: return "Verify & Install"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .calibrate: return "slider.horizontal.3"
|
||||
case .generate: return "square.grid.3x3"
|
||||
case .layOutPrint: return "printer"
|
||||
case .measure: return "eyedropper.halffull"
|
||||
case .buildProfile: return "paintpalette"
|
||||
case .verifyInstall: return "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
/// Stages shown in the sidebar stepper, in order.
|
||||
public static var stepperStages: [WizardStage] {
|
||||
[.generate, .layOutPrint, .measure, .buildProfile, .verifyInstall]
|
||||
}
|
||||
}
|
||||
@@ -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
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Mock chartread for bundled/manual testing.
|
||||
# Supports handheld and XY modes. Writes basename.ti3 on 'd'.
|
||||
MODE="${MOCK_CHARTREAD_MODE:-strip}"
|
||||
BASENAME=""
|
||||
|
||||
# Basename is the last non-flag argument.
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-*) ;;
|
||||
*) BASENAME="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
read_input() {
|
||||
IFS= read -r line || return 1
|
||||
}
|
||||
|
||||
emit_row() {
|
||||
printf 'ROW_COLORS_JSON: %s\n' "$1"
|
||||
}
|
||||
|
||||
write_ti3() {
|
||||
if [ -n "$BASENAME" ]; then
|
||||
echo "MOCK_TI3" > "${BASENAME}.ti3"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$MODE" = "xy" ]; then
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read_input
|
||||
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_input
|
||||
|
||||
echo "locate patch A1 with the sight,"
|
||||
echo "then hit return to continue"
|
||||
read_input
|
||||
|
||||
echo "Reading sheet 1..."
|
||||
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||
|
||||
echo "Sheet 1 of 1 read OK"
|
||||
echo "Please remove last sheet from table"
|
||||
echo "'d' if/when done"
|
||||
while read_input; do
|
||||
case "$line" in
|
||||
d*) write_ti3; exit 0 ;;
|
||||
q*) exit 0 ;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Handheld / strip mode (default)
|
||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||
read_input
|
||||
echo "Calibration successful."
|
||||
|
||||
echo "Hit [Space] to read strip A"
|
||||
read_input
|
||||
echo "Reading strip A..."
|
||||
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
|
||||
|
||||
echo "Hit [Space] to read strip B"
|
||||
read_input
|
||||
echo "Reading strip B..."
|
||||
emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}'
|
||||
|
||||
echo "'d' if/when done"
|
||||
while read_input; do
|
||||
case "$line" in
|
||||
d*) write_ti3; exit 0 ;;
|
||||
q*) exit 0 ;;
|
||||
esac
|
||||
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,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.800",
|
||||
"green" : "0.478",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ICCery-logo.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "original"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="100%" height="100%">
|
||||
<defs>
|
||||
<clipPath id="coneClip">
|
||||
<path d="M 50 82 L 110 82 L 80 142 Z" />
|
||||
</clipPath>
|
||||
<linearGradient id="textGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00AEEF" />
|
||||
<stop offset="100%" stop-color="#0066CC" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Drop Shadow -->
|
||||
<ellipse cx="80" cy="145" rx="25" ry="5" fill="#1E293B" opacity="0.1" />
|
||||
|
||||
<!-- Ice Cream Cone (Waffle) -->
|
||||
<path d="M 50 82 L 110 82 L 80 142 Z" fill="#FAD7A1" stroke="#E59866" stroke-width="2.5" stroke-linejoin="round"/>
|
||||
<g clip-path="url(#coneClip)" stroke="#E59866" stroke-width="2">
|
||||
<line x1="40" y1="80" x2="120" y2="160" />
|
||||
<line x1="55" y1="80" x2="135" y2="160" />
|
||||
<line x1="70" y1="80" x2="150" y2="160" />
|
||||
<line x1="85" y1="80" x2="165" y2="160" />
|
||||
|
||||
<line x1="120" y1="80" x2="40" y2="160" />
|
||||
<line x1="105" y1="80" x2="25" y2="160" />
|
||||
<line x1="90" y1="80" x2="10" y2="160" />
|
||||
<line x1="75" y1="80" x2="-5" y2="160" />
|
||||
</g>
|
||||
|
||||
<!-- CMYK Scoops (C, M, Y) -->
|
||||
<circle cx="63" cy="72" r="22" fill="#00BCEB" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="97" cy="72" r="22" fill="#EC008C" stroke="#ffffff" stroke-width="2.5"/>
|
||||
<circle cx="80" cy="48" r="22" fill="#FFED00" stroke="#ffffff" stroke-width="2.5"/>
|
||||
|
||||
<!-- Black (Key) Cherry -->
|
||||
<path d="M 80 23 Q 92 12 96 16" fill="none" stroke="#1E293B" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="80" cy="24" r="7" fill="#1E293B" stroke="#ffffff" stroke-width="1.5"/>
|
||||
|
||||
<!-- Highlights on scoops for 3D effect -->
|
||||
<circle cx="58" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||
<circle cx="92" cy="67" r="4" fill="#ffffff" opacity="0.35"/>
|
||||
<circle cx="75" cy="43" r="4" fill="#ffffff" opacity="0.45"/>
|
||||
<circle cx="78" cy="22" r="1.5" fill="#ffffff" opacity="0.6"/>
|
||||
|
||||
<!-- Text: ICCery -->
|
||||
<text font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="58" font-weight="800" x="140" y="105" fill="#ffffff" letter-spacing="-1">ICC<tspan fill="url(#textGrad)">ery</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- App Sandbox intentionally absent: ICCery must spawn Argyll tools,
|
||||
read/write user-chosen working directories, and talk to lp/CUPS. -->
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import AppKit
|
||||
import ICCeryCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ICCeryApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@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(workflow: workflow)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 800)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultPosition(.center)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !terminationRequested else { return .terminateNow }
|
||||
terminationRequested = true
|
||||
Task {
|
||||
await ProcessManager.shared.killAll()
|
||||
NSApplication.shared.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// A single evaluated swatch for the live grid.
|
||||
struct Swatch: Sendable, Equatable, Identifiable {
|
||||
let rowId: String
|
||||
let loc: String
|
||||
let isPad: Bool
|
||||
let intended: DisplayRGB
|
||||
let measured: DisplayRGB
|
||||
let deltaE: Double?
|
||||
let classification: SwatchClassification
|
||||
|
||||
var id: String { "\(rowId)\(loc)" }
|
||||
}
|
||||
|
||||
/// A row of swatches in display order.
|
||||
struct SwatchRow: Sendable, Equatable, Identifiable {
|
||||
let index: Int
|
||||
let rowId: String
|
||||
let patches: [Swatch]
|
||||
|
||||
var id: String { rowId }
|
||||
}
|
||||
|
||||
/// Stage of the XY-table badge bar.
|
||||
enum XYStep: Equatable, Sendable {
|
||||
case place, align, scan, remove
|
||||
}
|
||||
|
||||
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MeasurementWorkflowViewModel {
|
||||
|
||||
// MARK: - Authorities
|
||||
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
|
||||
// MARK: - Settings-driven thresholds
|
||||
|
||||
private(set) var goodMax: Double = 2.0
|
||||
private(set) var warningMax: Double = 5.0
|
||||
private(set) var enableLEDs: Bool = false
|
||||
|
||||
// MARK: - Instrument detection
|
||||
|
||||
var instruments: [InstrumentDevice] = []
|
||||
var selectedInstrument: InstrumentSelection = .auto
|
||||
var isDetecting = false
|
||||
var detectionError: String?
|
||||
|
||||
// MARK: - Chartread session
|
||||
|
||||
var isChartreadRunning = false
|
||||
var chartreadState: ChartreadState = .idle
|
||||
var currentPrompt: String?
|
||||
var requestedWarningKey: String?
|
||||
var chartreadLog: [String] = []
|
||||
var rows: [ChartreadRow] = []
|
||||
var swatchRows: [SwatchRow] = []
|
||||
var showRemoveSheetNotice = false
|
||||
var lastError: String?
|
||||
private var chartreadTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Averaging
|
||||
|
||||
var passSnapshots: [URL] = []
|
||||
var isFinishing = false
|
||||
var finishNotice: String?
|
||||
var finishNoticeIsError = false
|
||||
var resumedFromTi2 = false
|
||||
|
||||
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||
self.wizard = wizard
|
||||
self.environment = environment
|
||||
loadSettings()
|
||||
discoverPassSnapshots()
|
||||
}
|
||||
|
||||
// MARK: - Derived state
|
||||
|
||||
var basename: String { wizard.basename }
|
||||
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
|
||||
|
||||
var canDetect: Bool { !isDetecting }
|
||||
|
||||
var canStartRead: Bool {
|
||||
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
|
||||
}
|
||||
|
||||
var canMeasureAnotherSheet: Bool {
|
||||
isFinished && !passSnapshots.isEmpty
|
||||
}
|
||||
|
||||
var canFinish: Bool {
|
||||
isFinished && !passSnapshots.isEmpty
|
||||
}
|
||||
|
||||
var isFinished: Bool {
|
||||
chartreadState == .allStripsRead || chartreadState == .finished
|
||||
}
|
||||
|
||||
var xyStep: XYStep {
|
||||
if showRemoveSheetNotice { return .remove }
|
||||
switch chartreadState {
|
||||
case .tablePlaceSheet:
|
||||
return .place
|
||||
case .tableAlign:
|
||||
return .align
|
||||
case .reading, .awaitingStrip:
|
||||
return .scan
|
||||
default:
|
||||
return .place
|
||||
}
|
||||
}
|
||||
|
||||
var hasCanonicalTi3: Bool {
|
||||
guard let cwd = workingDirectory else { return false }
|
||||
let url = cwd.appendingPathComponent("\(basename).ti3")
|
||||
return FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func loadSettings() {
|
||||
let settings = environment.settingsStore.load()
|
||||
goodMax = settings.deltaEGoodMax
|
||||
warningMax = settings.deltaEWarningMax
|
||||
enableLEDs = settings.enableI1Pro2Leds
|
||||
}
|
||||
|
||||
// MARK: - Instrument detection
|
||||
|
||||
func detectInstruments() {
|
||||
guard !isDetecting else { return }
|
||||
isDetecting = true
|
||||
detectionError = nil
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let devices = try await self.environment.runner.detectInstruments()
|
||||
self.instruments = devices
|
||||
if case .device(let selected) = self.selectedInstrument,
|
||||
!devices.contains(where: { $0.port == selected.port }) {
|
||||
self.selectedInstrument = .auto
|
||||
}
|
||||
} catch {
|
||||
self.detectionError = error.localizedDescription
|
||||
}
|
||||
self.isDetecting = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chartread lifecycle
|
||||
|
||||
func startRead() {
|
||||
guard canStartRead, let cwd = workingDirectory else { return }
|
||||
let config = buildChartreadConfig(cwd: cwd)
|
||||
startChartread(config: config)
|
||||
}
|
||||
|
||||
func measureAnotherSheet() {
|
||||
guard let cwd = workingDirectory, isFinished else { return }
|
||||
let config = buildChartreadConfig(cwd: cwd)
|
||||
startChartread(config: config)
|
||||
}
|
||||
|
||||
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
|
||||
ChartreadConfig(
|
||||
basename: basename,
|
||||
workingDirectory: cwd,
|
||||
selectedPort: selectedInstrument.chartreadPort,
|
||||
enableLEDs: enableLEDs,
|
||||
isXY: selectedInstrument.isXY
|
||||
)
|
||||
}
|
||||
|
||||
private func startChartread(config: ChartreadConfig) {
|
||||
guard !isChartreadRunning else { return }
|
||||
|
||||
isChartreadRunning = true
|
||||
chartreadState = .idle
|
||||
currentPrompt = nil
|
||||
lastError = nil
|
||||
chartreadLog.removeAll()
|
||||
|
||||
// Optional: reset rows when starting a fresh first pass.
|
||||
if passSnapshots.isEmpty {
|
||||
rows.removeAll()
|
||||
swatchRows.removeAll()
|
||||
}
|
||||
|
||||
let stream = environment.runner.runChartread(config: config)
|
||||
|
||||
chartreadTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
for await event in stream {
|
||||
self.handle(event: event)
|
||||
}
|
||||
self.isChartreadRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(event: ChartreadEvent) {
|
||||
switch event {
|
||||
case .prompt(let result):
|
||||
chartreadState = result.state
|
||||
currentPrompt = promptText(for: result)
|
||||
requestedWarningKey = result.requestedWarningKey
|
||||
showRemoveSheetNotice = result.isRemoveSheetNotice
|
||||
|
||||
case .row(let row):
|
||||
upsert(row: row)
|
||||
if row.isFinalRow {
|
||||
chartreadState = .allStripsRead
|
||||
}
|
||||
|
||||
case .log(let batch):
|
||||
chartreadLog.append(contentsOf: batch)
|
||||
|
||||
case .removeSheetNotice:
|
||||
showRemoveSheetNotice = true
|
||||
|
||||
case .exit(let code):
|
||||
if code != 0 {
|
||||
lastError = "chartread exited with code \(code)"
|
||||
}
|
||||
|
||||
case .completed(let canonicalURL):
|
||||
chartreadState = .finished
|
||||
completePass(canonicalURL: canonicalURL)
|
||||
|
||||
case .failed(let error):
|
||||
lastError = error.localizedDescription
|
||||
chartreadState = .error
|
||||
isChartreadRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
private func promptText(for result: ChartreadClassifyResult) -> String {
|
||||
switch result.state {
|
||||
case .calibrating:
|
||||
return "Place instrument on calibration tile and press Calibrate."
|
||||
case .awaitingStrip:
|
||||
return "Press a key to read the next strip."
|
||||
case .allStripsRead:
|
||||
return "All strips read. Press Done & Save when ready."
|
||||
case .warning:
|
||||
if let key = result.requestedWarningKey {
|
||||
return "Warning — press '\(key.uppercased())' to continue."
|
||||
}
|
||||
return "Warning — press Continue."
|
||||
case .promptContinue:
|
||||
return "Press Continue."
|
||||
case .tablePlaceSheet:
|
||||
if let n = result.sheetNumber, let t = result.sheetTotal {
|
||||
return "Place sheet \(n) of \(t) on the table."
|
||||
}
|
||||
return "Place the sheet on the table."
|
||||
case .tableAlign:
|
||||
if let patch = result.alignmentPatch {
|
||||
return "Locate patch \(patch) with the sight, then continue."
|
||||
}
|
||||
return "Align the fiducial, then continue."
|
||||
case .reading:
|
||||
return "Reading..."
|
||||
case .error:
|
||||
return "Read error — you can Retry or Cancel."
|
||||
case .finished:
|
||||
return "Measurement saved."
|
||||
case .idle:
|
||||
return "Press Start to begin reading."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User actions
|
||||
|
||||
func calibrate() {
|
||||
send(.trigger)
|
||||
}
|
||||
|
||||
func accept() {
|
||||
if let key = requestedWarningKey {
|
||||
send(.customKey(key))
|
||||
requestedWarningKey = nil
|
||||
} else {
|
||||
send(.accept)
|
||||
}
|
||||
}
|
||||
|
||||
func retry() {
|
||||
send(.trigger)
|
||||
}
|
||||
|
||||
func doneAndSave() {
|
||||
send(.done)
|
||||
}
|
||||
|
||||
func cancelRead() {
|
||||
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
|
||||
chartreadTask?.cancel()
|
||||
isChartreadRunning = false
|
||||
}
|
||||
|
||||
func sendWarningKey(_ key: String) {
|
||||
send(.customKey(key))
|
||||
}
|
||||
|
||||
private func send(_ input: ChartreadInput) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.isChartreadRunning else { return }
|
||||
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows and swatches
|
||||
|
||||
private func upsert(row: ChartreadRow) {
|
||||
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
|
||||
rows[index] = row
|
||||
} else {
|
||||
rows.append(row)
|
||||
}
|
||||
rows.sort { $0.rowIndex < $1.rowIndex }
|
||||
recomputeSwatches()
|
||||
}
|
||||
|
||||
func recomputeSwatches() {
|
||||
var displayRows: [SwatchRow] = []
|
||||
for (rowIndex, row) in rows.enumerated() {
|
||||
var swatches: [Swatch] = []
|
||||
for patch in row.patches {
|
||||
let skip = shouldSkipPad(patch)
|
||||
if skip { continue }
|
||||
|
||||
let eval = ColorDifference.evaluate(
|
||||
patch: patch,
|
||||
goodMax: goodMax,
|
||||
warningMax: warningMax
|
||||
)
|
||||
if let eval {
|
||||
swatches.append(Swatch(
|
||||
rowId: row.rowId,
|
||||
loc: patch.loc,
|
||||
isPad: patch.isPad,
|
||||
intended: eval.intended,
|
||||
measured: eval.measured,
|
||||
deltaE: eval.deltaE,
|
||||
classification: eval.classification
|
||||
))
|
||||
}
|
||||
}
|
||||
if !swatches.isEmpty {
|
||||
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
|
||||
}
|
||||
}
|
||||
swatchRows = displayRows
|
||||
}
|
||||
|
||||
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
|
||||
guard patch.isPad else { return false }
|
||||
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
|
||||
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
|
||||
return measuredEmpty && deviceAllZero
|
||||
}
|
||||
|
||||
// MARK: - Pass management
|
||||
|
||||
private func completePass(canonicalURL: URL) {
|
||||
guard let cwd = workingDirectory else { return }
|
||||
do {
|
||||
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
|
||||
discoverPassSnapshots()
|
||||
wizard.refreshGating()
|
||||
} catch {
|
||||
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func discoverPassSnapshots() {
|
||||
guard let cwd = workingDirectory else {
|
||||
passSnapshots = []
|
||||
return
|
||||
}
|
||||
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
|
||||
}
|
||||
|
||||
// MARK: - Finish / Average
|
||||
|
||||
func finishAndAverage() {
|
||||
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||
isFinishing = true
|
||||
finishNotice = nil
|
||||
finishNoticeIsError = false
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let canonical: URL
|
||||
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||
canonical = try MeasurementArtefacts.promotePass(
|
||||
pass: pass,
|
||||
basename: self.basename,
|
||||
cwd: cwd
|
||||
)
|
||||
} else {
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
basename: self.basename,
|
||||
passFiles: self.passSnapshots
|
||||
)
|
||||
canonical = try await self.environment.runner.runAverage(
|
||||
config: config,
|
||||
onLogBatch: { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.chartreadLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
self.discoverPassSnapshots()
|
||||
self.wizard.refreshGating()
|
||||
if self.wizard.isUnlocked(.buildProfile) {
|
||||
self.wizard.go(to: .buildProfile)
|
||||
} else {
|
||||
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
||||
}
|
||||
} catch {
|
||||
// Fallback to pass 1 promotion if averaging failed.
|
||||
if let pass = self.passSnapshots.first {
|
||||
do {
|
||||
_ = try MeasurementArtefacts.promotePass(
|
||||
pass: pass,
|
||||
basename: self.basename,
|
||||
cwd: cwd
|
||||
)
|
||||
self.discoverPassSnapshots()
|
||||
self.wizard.refreshGating()
|
||||
self.finishNotice = "Averaging failed — promoted first pass."
|
||||
self.finishNoticeIsError = true
|
||||
} catch {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
}
|
||||
} else {
|
||||
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||
self.finishNoticeIsError = true
|
||||
}
|
||||
}
|
||||
self.isFinishing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Banner notice model — the v2 equivalent of `#wizardNotification`
|
||||
/// (docs/21 §Banner).
|
||||
struct Notice: Identifiable, Equatable {
|
||||
enum Kind: Equatable {
|
||||
case info, warning, error
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .info: return "info.circle"
|
||||
case .warning: return "exclamationmark.triangle"
|
||||
case .error: return "xmark.octagon"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .info: return Theme.accent
|
||||
case .warning: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let text: String
|
||||
/// Auto-dismiss interval; `nil` keeps the banner until closed.
|
||||
var autoHideAfter: TimeInterval? = 6
|
||||
}
|
||||
|
||||
struct NoticeBanner: View {
|
||||
let notice: Notice
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: notice.kind.symbolName)
|
||||
.foregroundStyle(notice.kind.tint)
|
||||
Text(notice.text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(3)
|
||||
.accessibilityIdentifier("noticeText")
|
||||
.accessibilityValue(notice.text)
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Theme.panel)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundStyle(Theme.border),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// `#savePresetDialog` — save the live Stage 1/2 form as a custom
|
||||
/// preset (issue #11). Names/descriptions render via `Text` only (#114).
|
||||
struct SavePresetDialog: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Save Preset").font(.title3).foregroundStyle(Theme.text)
|
||||
TextField("Name", text: $workflow.savePresetName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("savePresetName")
|
||||
TextField("Description (optional)", text: $workflow.savePresetDesc)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("savePresetDesc")
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { workflow.showingSavePreset = false }
|
||||
.accessibilityIdentifier("btnCloseSavePresetDialog")
|
||||
Button("Save") { workflow.saveCurrentAsPreset() }
|
||||
.accessibilityIdentifier("btnConfirmSavePreset")
|
||||
.disabled(workflow.savePresetName
|
||||
.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("savePresetDialog")
|
||||
}
|
||||
}
|
||||
|
||||
/// `#managePresetsDialog` — list, delete (custom only), import, export.
|
||||
struct ManagePresetsDialog: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Manage Presets").font(.title3).foregroundStyle(Theme.text)
|
||||
List {
|
||||
ForEach(workflow.presets) { preset in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(preset.name).foregroundStyle(Theme.text)
|
||||
if !preset.description.isEmpty {
|
||||
Text(preset.description)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if PresetCatalog.isBuiltIn(preset.id) {
|
||||
Text("Built-in")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Button("Export") { workflow.exportPreset(preset) }
|
||||
.accessibilityIdentifier(
|
||||
"btnExportPreset-\(preset.id)")
|
||||
Button("Delete", role: .destructive) {
|
||||
workflow.deletePreset(preset)
|
||||
}
|
||||
.accessibilityIdentifier("btnDeletePreset-\(preset.id)")
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("presetRow-\(preset.id)")
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("managePresetsList")
|
||||
.frame(minHeight: 240)
|
||||
HStack {
|
||||
Button("Import…") { workflow.importPreset() }
|
||||
.accessibilityIdentifier("btnImportPreset")
|
||||
if let selected = workflow.selectedPreset,
|
||||
!PresetCatalog.isBuiltIn(selected.id) {
|
||||
Button("Export Active") { workflow.exportPreset(selected) }
|
||||
.accessibilityIdentifier("btnExportActivePreset")
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") { workflow.showingManagePresets = false }
|
||||
.accessibilityIdentifier("btnCloseManagePresetsDialog")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 480)
|
||||
.background(Theme.background)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("managePresetsDialog")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import ICCeryCore
|
||||
|
||||
/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||
/// The second argument is the mode string — never integer `1` (#188).
|
||||
typealias ColorMatchingModeFunction =
|
||||
@convention(c) (PMPrintSession, CFString) -> OSStatus
|
||||
|
||||
/// `PMPrintSettingsToOptions` — public symbol, resolved via dlsym so a
|
||||
/// missing SDK declaration can't break the build.
|
||||
typealias PrintSettingsToOptionsFunction =
|
||||
@convention(c) (PMPrintSettings, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) -> OSStatus
|
||||
|
||||
/// The six-layer unmanaged-printing engine (issue 14, docs/11):
|
||||
///
|
||||
/// ① session binding — done by `PrintPanelService` before calling us.
|
||||
/// ② private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}` —
|
||||
/// resolved by `dlsym(RTLD_DEFAULT,…)`; first `(symbol, mode)`
|
||||
/// returning `0` wins.
|
||||
/// ③ `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and
|
||||
/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked.
|
||||
/// ④ driver "no colour adjustment" pre-select from `lpoptions -l`
|
||||
/// keys, unlocked (`detectDriverColorBypass`).
|
||||
/// ⑤ mirror ③+④ into `NSPrintInfo.printSettings` so the PDE sees them.
|
||||
/// ⑥ after "Use Settings": `PMPrintSettingsToOptions` →
|
||||
/// `CupsOptionsFilter` → captured `cupsOptions` + `mediaType`.
|
||||
///
|
||||
/// All layers degrade gracefully — a missing symbol or non-zero status
|
||||
/// is logged and the next layer still runs.
|
||||
@MainActor
|
||||
struct ColorSyncSuppressor {
|
||||
|
||||
/// Injected for tests: symbol → function. Default resolves via
|
||||
/// `dlsym(RTLD_DEFAULT, …)`.
|
||||
typealias ModeResolver = (String) -> ColorMatchingModeFunction?
|
||||
typealias OptionsResolver = () -> PrintSettingsToOptionsFunction?
|
||||
|
||||
var modeResolver: ModeResolver = Self.dlsymMode
|
||||
var optionsResolver: OptionsResolver = Self.dlsymOptions
|
||||
var log: (String) -> Void = { AppLogger.shared.log(.info, $0) }
|
||||
|
||||
// MARK: - Layer ② SPI
|
||||
|
||||
/// Walk `ColorMatchingAttempts.attempts` (Lock → plain → NoLock ×
|
||||
/// `AP_ApplicationColorMatching` → `ApplicationColorMatching`); the
|
||||
/// first call returning `0` wins. `false` when nothing worked.
|
||||
@discardableResult
|
||||
func applySPIMode(to session: PMPrintSession) -> Bool {
|
||||
for attempt in ColorMatchingAttempts.attempts {
|
||||
guard let function = modeResolver(attempt.symbol) else {
|
||||
continue
|
||||
}
|
||||
let status = function(session, attempt.mode as CFString)
|
||||
if status == 0 {
|
||||
log("ColorSync: \(attempt.symbol) accepted "
|
||||
+ "\(attempt.mode)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
log("ColorSync: no PMSessionSetColorMatchingMode* accepted a "
|
||||
+ "mode — falling back to PMPrintSettingsSetValue")
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Layer ③ locked AP_* keys
|
||||
|
||||
/// `PMPrintSettingsSetValue` both key spellings, locked.
|
||||
@discardableResult
|
||||
func applyLockedKeys(to settings: PMPrintSettings) -> Int {
|
||||
var applied = 0
|
||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||
let status = PMPrintSettingsSetValue(
|
||||
settings,
|
||||
key as CFString,
|
||||
ColorMatchingAttempts.applicationMatchingValue as CFString,
|
||||
true)
|
||||
if status == 0 { applied += 1 }
|
||||
}
|
||||
if applied == 0 {
|
||||
log("ColorSync: PMPrintSettingsSetValue could not lock "
|
||||
+ "AP_ColorMatchingMode")
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// MARK: - Layer ④ driver bypass
|
||||
|
||||
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
||||
/// the PDE may override it. Returns the `(key, value)` applied.
|
||||
@discardableResult
|
||||
func applyDriverBypass(
|
||||
to settings: PMPrintSettings,
|
||||
optionKeys: Set<String>
|
||||
) -> (key: String, value: String)? {
|
||||
guard let bypass = CupsParsers.detectDriverColorBypass(
|
||||
optionKeys: optionKeys)
|
||||
else { return nil }
|
||||
let status = PMPrintSettingsSetValue(
|
||||
settings,
|
||||
bypass.key as CFString,
|
||||
bypass.value as CFString,
|
||||
false)
|
||||
if status != 0 {
|
||||
log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) "
|
||||
+ "rejected (\(status))")
|
||||
return nil
|
||||
}
|
||||
return bypass
|
||||
}
|
||||
|
||||
// MARK: - Layer ⑤ NSPrintInfo mirror
|
||||
|
||||
/// Mirror the applied keys into `printSettings` so the PDE pick
|
||||
/// sees them.
|
||||
func mirror(
|
||||
into printInfo: NSPrintInfo,
|
||||
driverBypass: (key: String, value: String)?
|
||||
) {
|
||||
let settings = printInfo.printSettings
|
||||
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
||||
}
|
||||
if let driverBypass {
|
||||
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layer ⑥ capture
|
||||
|
||||
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`.
|
||||
/// The malloc'd C string is freed after copying.
|
||||
func captureOptions(
|
||||
from settings: PMPrintSettings
|
||||
) -> (cupsOptions: String?, mediaType: String?) {
|
||||
guard let toOptions = optionsResolver() else {
|
||||
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
||||
+ "panel options not captured")
|
||||
return (nil, nil)
|
||||
}
|
||||
var raw: UnsafeMutablePointer<CChar>?
|
||||
guard toOptions(settings, &raw) == 0, let raw else {
|
||||
return (nil, nil)
|
||||
}
|
||||
defer { free(raw) }
|
||||
let unfiltered = String(cString: raw)
|
||||
let filtered = CupsOptionsFilter.filter(unfiltered)
|
||||
return (
|
||||
filtered.isEmpty ? nil : filtered,
|
||||
CupsParsers.extractMediaType(fromOptionsString: unfiltered)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - dlsym
|
||||
|
||||
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
||||
guard let symbol = dlsym(Self.rtldDefault, name) else { return nil }
|
||||
return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self)
|
||||
}
|
||||
|
||||
private static func dlsymOptions() -> PrintSettingsToOptionsFunction? {
|
||||
guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions")
|
||||
else { return nil }
|
||||
return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self)
|
||||
}
|
||||
|
||||
/// `RTLD_DEFAULT` — `UnsafeMutableRawPointer(bitPattern: -2)`.
|
||||
private static var rtldDefault: UnsafeMutableRawPointer? {
|
||||
UnsafeMutableRawPointer(bitPattern: -2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import ICCeryCore
|
||||
|
||||
/// Errors raised while preparing the bound print panel.
|
||||
enum PrintPanelError: LocalizedError {
|
||||
case sessionBindingFailed(OSStatus)
|
||||
case noPrinterFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sessionBindingFailed(let status):
|
||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||
case .noPrinterFound(let name):
|
||||
return "No printer found for '\(name)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
/// This is a **settings-capture** dialog — the default button is
|
||||
/// "Use Settings", never "Print". It is never System Settings, the
|
||||
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
|
||||
/// and is not an error.
|
||||
///
|
||||
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)` →
|
||||
/// `PMSessionSetCurrentPMPrinter` → session default settings/page
|
||||
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
|
||||
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
|
||||
/// label) → `printInfo.printer`.
|
||||
@MainActor
|
||||
struct PrintPanelService {
|
||||
|
||||
/// The suppression engine — injectable for tests.
|
||||
var suppressor = ColorSyncSuppressor()
|
||||
|
||||
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
|
||||
/// the modal panel. Returns `nil` when the user cancels.
|
||||
func showProperties(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
cupsService: CupsService
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
return UITestHooks.printPanelResult(forQueue: queue)
|
||||
}
|
||||
#endif
|
||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||
let fetched = try? await cupsService.displayName(for: queue)
|
||||
let display = displayName ?? fetched
|
||||
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
||||
// the driver colour-bypass before the panel opens.
|
||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||
?? []
|
||||
return try runNativePanel(
|
||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
private func runNativePanel(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>
|
||||
) throws -> PrintPropertiesResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
var boundViaPM = false
|
||||
|
||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
||||
pmPrinter = printer
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
let settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let pageFormat = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
|
||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||
if status != 0 {
|
||||
PMRelease(Self.pmObject(printer))
|
||||
throw PrintPanelError.sessionBindingFailed(status)
|
||||
}
|
||||
// Warn-only: defaults keep the panel consistent with the
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||
boundViaPM = true
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
guard let displayName,
|
||||
let nsPrinter = NSPrinter(name: displayName)
|
||||
else {
|
||||
throw PrintPanelError.noPrinterFound(
|
||||
displayName ?? queue)
|
||||
}
|
||||
printInfo.printer = nsPrinter
|
||||
printInfo.setUpPrintOperationDefaultValues()
|
||||
}
|
||||
defer {
|
||||
if let printer = pmPrinter {
|
||||
PMRelease(Self.pmObject(printer))
|
||||
}
|
||||
}
|
||||
|
||||
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||
// and PMPrintSettingsSetValue need a session with a current
|
||||
// printer to attach to.
|
||||
var settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
var driverBypass: (key: String, value: String)?
|
||||
if boundViaPM {
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
suppressor.applySPIMode(to: session) // ②
|
||||
suppressor.applyLockedKeys(to: settings) // ③
|
||||
driverBypass = suppressor.applyDriverBypass( // ④
|
||||
to: settings, optionKeys: optionKeys)
|
||||
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||
}
|
||||
|
||||
let panel = NSPrintPanel()
|
||||
panel.options = [
|
||||
.showsCopies, .showsPageRange, .showsPaperSize,
|
||||
.showsOrientation, .showsScaling, .showsPrintSelection,
|
||||
.showsPageSetupAccessory, .showsPreview,
|
||||
]
|
||||
panel.setDefaultButtonTitle("Use Settings")
|
||||
|
||||
let response = panel.runModal(with: printInfo)
|
||||
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ⑥ Capture the user's choices — filtered replay options plus
|
||||
// the media type they picked. Re-fetch the settings handle so
|
||||
// we read back what the modal wrote.
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let captured = suppressor.captureOptions(from: settings)
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
? Self.currentPrinterID(
|
||||
session: unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
mediaType: mediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
}
|
||||
|
||||
// MARK: - PM helpers
|
||||
|
||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
||||
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
||||
unsafeBitCast(printer, to: PMObject.self)
|
||||
}
|
||||
|
||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||
private static func currentPrinterID(
|
||||
session: PMPrintSession,
|
||||
fallback: String
|
||||
) -> String {
|
||||
var current: PMPrinter?
|
||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||
let printer = current
|
||||
else { return fallback }
|
||||
defer { PMRelease(pmObject(printer)) }
|
||||
guard let id = PMPrinterGetID(printer)
|
||||
else { return fallback }
|
||||
return id.takeUnretainedValue() as String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 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(
|
||||
workflow: workflow,
|
||||
onOpenSettings: { showingSettings = true },
|
||||
onOpenAbout: { showingAbout = true }
|
||||
)
|
||||
|
||||
Rectangle()
|
||||
.fill(Theme.border)
|
||||
.frame(width: 1)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if let notice = model.notice {
|
||||
NoticeBanner(notice: notice, onClose: model.dismissNotice)
|
||||
}
|
||||
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) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $workflow.showingSavePreset) {
|
||||
SavePresetDialog(workflow: workflow)
|
||||
}
|
||||
.sheet(isPresented: $workflow.showingManagePresets) {
|
||||
ManagePresetsDialog(workflow: workflow)
|
||||
}
|
||||
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
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:
|
||||
Stage3View(model: workflow.measurement)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
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 workflow: TargetWorkflowViewModel
|
||||
var onOpenSettings: () -> Void
|
||||
var onOpenAbout: () -> Void
|
||||
|
||||
private var model: WizardViewModel { workflow.wizard }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Image("ICCery-logo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
Spacer()
|
||||
Button(action: onOpenSettings) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Settings")
|
||||
Button(action: onOpenAbout) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("About ICCery")
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
// 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)
|
||||
.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() }) {
|
||||
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(true)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Stepper 1–5.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(WizardStage.stepperStages, id: \.self) { stage in
|
||||
StepperRow(
|
||||
stage: stage,
|
||||
isActive: model.stage == stage,
|
||||
// Artefact gating (issue #4) — disk is truth.
|
||||
isEnabled: model.isUnlocked(stage)
|
||||
) {
|
||||
model.go(to: stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: Theme.Metrics.sidebarWidth)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StepperRow: View {
|
||||
let stage: WizardStage
|
||||
let isActive: Bool
|
||||
let isEnabled: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isActive ? Theme.accent : Theme.border)
|
||||
.frame(width: 26, height: 26)
|
||||
Text("\(stage.stepperIndex ?? 0)")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(isActive ? .white : Theme.text)
|
||||
}
|
||||
Label(stage.title, systemImage: stage.symbolName)
|
||||
.font(.callout)
|
||||
.foregroundStyle(isActive ? Theme.text : .secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.opacity(isEnabled ? 1 : 0.45)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,377 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 2 — `#stage-2` Lay Out & Print (`printtarg` → `.ti2` + TIFFs,
|
||||
/// issues #9/#10, docs/09). Print controls are visible but inert —
|
||||
/// real spooling lands in M3.
|
||||
struct Stage2View: View {
|
||||
@Bindable var workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
cmWarning
|
||||
formSection
|
||||
labelSection
|
||||
actionRow
|
||||
logSection
|
||||
gallerySection
|
||||
printPanel
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Theme.background)
|
||||
.accessibilityIdentifier("stage-2")
|
||||
}
|
||||
|
||||
// MARK: - Colour-management warning (#cmWarningBanner)
|
||||
|
||||
private var cmWarning: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Set your printer driver to “No Colour Adjustment” " +
|
||||
"(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " +
|
||||
"Any driver colour management corrupts the target.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
|
||||
.stroke(Color.orange.opacity(0.4))
|
||||
)
|
||||
.accessibilityIdentifier("cmWarningBanner")
|
||||
}
|
||||
|
||||
// MARK: - Layout form
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 16) {
|
||||
Picker("Instrument", selection: $workflow.instrument) {
|
||||
ForEach(PrintInstrument.allCases, id: \.self) {
|
||||
Text($0.displayName).tag($0)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("instrumentSelect")
|
||||
Picker("Page size", selection: $workflow.pageSize) {
|
||||
ForEach(PageSize.allCases, id: \.self) {
|
||||
Text($0.rawValue).tag($0)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("pageSizeSelect")
|
||||
}
|
||||
if workflow.pageSize == .custom {
|
||||
HStack(spacing: 8) {
|
||||
Text("Custom size (mm):")
|
||||
TextField("W", value: $workflow.customPageW, format: .number)
|
||||
.textFieldStyle(.roundedBorder).frame(width: 70)
|
||||
.accessibilityIdentifier("customPageW")
|
||||
Text("×")
|
||||
TextField("H", value: $workflow.customPageH, format: .number)
|
||||
.textFieldStyle(.roundedBorder).frame(width: 70)
|
||||
.accessibilityIdentifier("customPageH")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("customPageSizeRow")
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
Picker("Bit depth", selection: $workflow.bitDepth) {
|
||||
Text("8-bit TIFF").tag(TiffBitDepth.eight)
|
||||
Text("16-bit TIFF").tag(TiffBitDepth.sixteen)
|
||||
}
|
||||
Stepper("DPI: \(workflow.tiffDpi)",
|
||||
value: $workflow.tiffDpi, in: 72...600, step: 1)
|
||||
.accessibilityIdentifier("tiffDpi")
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
Picker("Layout order", selection: $workflow.layoutOrder) {
|
||||
ForEach(LayoutOrder.allCases, id: \.self) {
|
||||
Text($0.displayName).tag($0)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("printtargLayoutOrder")
|
||||
if workflow.layoutOrder == .customSeed {
|
||||
HStack {
|
||||
Text("Seed:")
|
||||
TextField("", value: $workflow.customSeed, format: .number)
|
||||
.textFieldStyle(.roundedBorder).frame(width: 80)
|
||||
.accessibilityIdentifier("printtargCustomSeed")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("printtargCustomSeedGroup")
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
|
||||
// MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview)
|
||||
|
||||
private var labelSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Chart label").font(.headline).foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") {
|
||||
workflow.labelIsCustom.toggle()
|
||||
}
|
||||
.accessibilityIdentifier("btnToggleLabelEdit")
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
TextField("Printer", text: $workflow.metaPrinter)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("targetMetadataPrinter")
|
||||
TextField("Ink set", text: $workflow.metaInkSet)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("targetMetadataInkSet")
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
TextField("Driver paper", text: $workflow.metaDriverPaper)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("targetMetadataDriverPaper")
|
||||
TextField("Actual paper", text: $workflow.metaActualPaper)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("targetMetadataActualPaper")
|
||||
}
|
||||
if workflow.labelIsCustom {
|
||||
TextField("Custom label", text: $workflow.customLabel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("targetLabelPreview")
|
||||
} else {
|
||||
Text(workflow.automaticLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("targetLabelPreview")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions + log
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack {
|
||||
Button(action: workflow.createLayout) {
|
||||
Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout",
|
||||
systemImage: "rectangle.grid.2x2")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty)
|
||||
.accessibilityIdentifier("btnCreateLayout")
|
||||
if workflow.printtargRunning {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var logSection: some View {
|
||||
DisclosureGroup("Process log") {
|
||||
ScrollView {
|
||||
Text(workflow.printtargLog.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(minHeight: 100, maxHeight: 180)
|
||||
.accessibilityIdentifier("printtargLog")
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("printtargLogContainer")
|
||||
}
|
||||
|
||||
// MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58)
|
||||
|
||||
@ViewBuilder
|
||||
private var gallerySection: some View {
|
||||
if let result = workflow.printtargResult {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Target pages — \(result.manifest.pages.count) page(s), " +
|
||||
"\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches")
|
||||
.font(.headline).foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("galleryInfo")
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 220))],
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(result.pages) { page in
|
||||
GalleryPageView(page: page, workflow: workflow)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("galleryGrid")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("tiffGallery")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
|
||||
|
||||
private var printPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||
if let notice = workflow.printNotice {
|
||||
Image(systemName: workflow.printNoticeIsError
|
||||
? "xmark.circle.fill" : "info.circle.fill")
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
? .red : .blue)
|
||||
.accessibilityIdentifier("printNotificationIcon")
|
||||
Text(notice)
|
||||
.font(.caption)
|
||||
.foregroundStyle(workflow.printNoticeIsError
|
||||
? .red : .secondary)
|
||||
.accessibilityIdentifier("printNotificationText")
|
||||
.accessibilityValue(notice)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Printer row: select + status + refresh + Preferences.
|
||||
HStack(spacing: 10) {
|
||||
Picker("Printer", selection: $workflow.selectedPrinter) {
|
||||
ForEach(workflow.printers, id: \.name) { printer in
|
||||
Text(printer.displayName ?? printer.name)
|
||||
.tag(printer.name)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.accessibilityIdentifier("printerSelect")
|
||||
.onChange(of: workflow.selectedPrinter) { _, _ in
|
||||
workflow.selectedTray = nil
|
||||
workflow.selectedMediaType = nil
|
||||
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.printers
|
||||
.first(where: { $0.name == workflow.selectedPrinter }) {
|
||||
Text(selected.status.rawValue)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(Theme.background)
|
||||
.clipShape(Capsule())
|
||||
.accessibilityIdentifier("printerStatusBadge")
|
||||
}
|
||||
Button(action: workflow.refreshPrinters) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh printer list")
|
||||
.accessibilityIdentifier("btnRefreshPrinters")
|
||||
Button(action: workflow.openPrinterPreferences) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.help("Printer properties — bound NSPrintPanel")
|
||||
.disabled(workflow.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.selectedTray) {
|
||||
ForEach(workflow.printerCaps.trays, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerTraySelect")
|
||||
}
|
||||
if !workflow.printerCaps.mediaTypes.isEmpty {
|
||||
Picker("Media", selection: $workflow.selectedMediaType) {
|
||||
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 240)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("mediaTypeGroup")
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientPortrait")
|
||||
Button("Landscape") { workflow.printOrientation = "landscape" }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
||||
.accessibilityIdentifier("btnOrientLandscape")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: workflow.printAllPages) {
|
||||
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
||||
systemImage: "printer")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.disabled(workflow.isPrinting
|
||||
|| workflow.printtargResult == nil
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintAll")
|
||||
Spacer()
|
||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||
.disabled(workflow.printtargResult == nil
|
||||
|| !workflow.wizard.isUnlocked(.measure))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.panel)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("rawPrintPanel")
|
||||
.task(id: workflow.printtargResult?.pages.count) {
|
||||
// Auto-enumerate once a manifest exists and whenever it
|
||||
// changes (e.g. resume from .ti2).
|
||||
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
||||
workflow.refreshPrinters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One gallery cell: PNG preview + per-page Print button.
|
||||
private struct GalleryPageView: View {
|
||||
let page: GalleryPage
|
||||
let workflow: TargetWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
if let png = page.previewPNG, let image = NSImage(data: png) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 240)
|
||||
} else {
|
||||
ZStack {
|
||||
Rectangle().fill(Theme.panel).frame(height: 160)
|
||||
Text(page.previewError ?? "No preview")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(page.page.filename)
|
||||
.font(.caption).foregroundStyle(Theme.text)
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.printPage(page) }
|
||||
.disabled(workflow.isPrinting
|
||||
|| workflow.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
}
|
||||
.padding(8)
|
||||
.background(Theme.panel)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("galleryPage-\(page.index)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
|
||||
struct Stage3View: View {
|
||||
@Bindable var model: MeasurementWorkflowViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instrumentSection
|
||||
chartreadControlsSection
|
||||
xyTableSection
|
||||
swatchGridSection
|
||||
averagingSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Theme.background)
|
||||
.onAppear { model.discoverPassSnapshots() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in
|
||||
model.loadSettings()
|
||||
model.recomputeSwatches()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if model.resumedFromTi2 {
|
||||
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("stage3LoadedTargetBanner")
|
||||
}
|
||||
Text(model.basename)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("stage3TargetBasename")
|
||||
Text("Measure the printed chart with a spectrophotometer.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("stage3TargetMeta")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if model.isChartreadRunning {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.accessibilityIdentifier("readProgress")
|
||||
}
|
||||
|
||||
if let badge = targetBadge {
|
||||
Text(badge)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Theme.border)
|
||||
.cornerRadius(4)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("stage3TargetBadge")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
private var targetBadge: String? {
|
||||
if model.isFinished { return "All strips read" }
|
||||
if model.isChartreadRunning { return "Reading" }
|
||||
if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Instrument detection
|
||||
|
||||
@ViewBuilder
|
||||
private var instrumentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Instrument")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button(action: { model.detectInstruments() }) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!model.canDetect)
|
||||
.accessibilityIdentifier("btnDetectInstruments")
|
||||
}
|
||||
|
||||
if model.detectionError != nil {
|
||||
Text(model.detectionError ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Picker("Instrument", selection: Binding(
|
||||
get: { instrumentTag },
|
||||
set: { newTag in
|
||||
if newTag.isEmpty {
|
||||
model.selectedInstrument = .auto
|
||||
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
|
||||
model.selectedInstrument = .device(device)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text("Auto (first available port)").tag("")
|
||||
ForEach(model.instruments) { device in
|
||||
Text(device.displayName).tag("\(device.port)")
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.accessibilityIdentifier("chartreadInstrumentSelect")
|
||||
|
||||
if model.selectedInstrument.isXY {
|
||||
Text("XY table workflow selected.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("xyTableHint")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
private var instrumentTag: String {
|
||||
switch model.selectedInstrument {
|
||||
case .auto:
|
||||
return ""
|
||||
case .device(let device):
|
||||
return "\(device.port)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chartread controls
|
||||
|
||||
@ViewBuilder
|
||||
private var chartreadControlsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Text(model.currentPrompt ?? "Press Start to begin reading.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("chartreadPrompt")
|
||||
}
|
||||
|
||||
if model.showRemoveSheetNotice {
|
||||
Text("Please remove last sheet from table.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
|
||||
if let lastError = model.lastError {
|
||||
Text(lastError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityIdentifier("chartreadLastError")
|
||||
.accessibilityValue(lastError)
|
||||
}
|
||||
|
||||
controlButtons
|
||||
|
||||
if !model.chartreadLog.isEmpty {
|
||||
DisclosureGroup("Log") {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(model.chartreadLog, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Theme.text)
|
||||
.accessibilityIdentifier("chartreadLogContainer")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controlButtons: some View {
|
||||
HStack(spacing: 12) {
|
||||
if !model.isChartreadRunning {
|
||||
Button("Start Read") {
|
||||
model.startRead()
|
||||
}
|
||||
.disabled(!model.canStartRead)
|
||||
.accessibilityIdentifier("btnStartRead")
|
||||
}
|
||||
|
||||
if model.isChartreadRunning {
|
||||
switch model.chartreadState {
|
||||
case .calibrating:
|
||||
Button("Calibrate") { model.calibrate() }
|
||||
.accessibilityIdentifier("btnCalibrate")
|
||||
case .awaitingStrip:
|
||||
Button("Trigger") { model.calibrate() }
|
||||
.accessibilityIdentifier("btnCalibrate")
|
||||
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
|
||||
Button(continueTitle) { model.accept() }
|
||||
.accessibilityIdentifier("btnAccept")
|
||||
case .error:
|
||||
Button("Retry") { model.retry() }
|
||||
.accessibilityIdentifier("btnRetry")
|
||||
case .allStripsRead:
|
||||
Button("Done & Save") { model.doneAndSave() }
|
||||
.accessibilityIdentifier("btnDoneRead")
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
if model.chartreadState == .awaitingStrip || model.chartreadState == .allStripsRead {
|
||||
Button("Done & Save") { model.doneAndSave() }
|
||||
.accessibilityIdentifier("btnDoneRead")
|
||||
}
|
||||
|
||||
if model.chartreadState == .error {
|
||||
Button("Retry") { model.retry() }
|
||||
.accessibilityIdentifier("btnRetry")
|
||||
}
|
||||
|
||||
Button("Cancel") { model.cancelRead() }
|
||||
.accessibilityIdentifier("btnCancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var continueTitle: String {
|
||||
if let key = model.requestedWarningKey {
|
||||
return "Continue (send '\(key.uppercased())')"
|
||||
}
|
||||
return "Continue"
|
||||
}
|
||||
|
||||
// MARK: - XY table badges
|
||||
|
||||
@ViewBuilder
|
||||
private var xyTableSection: some View {
|
||||
if model.selectedInstrument.isXY {
|
||||
HStack(spacing: 8) {
|
||||
xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace")
|
||||
xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign")
|
||||
xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan")
|
||||
xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.panel)
|
||||
.accessibilityIdentifier("xyTablePanel")
|
||||
}
|
||||
}
|
||||
|
||||
private func xyStep(_ label: String, active: Bool, id: String) -> some View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(active ? .bold : .regular)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(active ? Theme.accent : Theme.border)
|
||||
.foregroundStyle(active ? Color.white : Theme.text)
|
||||
.cornerRadius(4)
|
||||
.accessibilityIdentifier(id)
|
||||
}
|
||||
|
||||
// MARK: - Swatch grid
|
||||
|
||||
@ViewBuilder
|
||||
private var swatchGridSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Swatches")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
statsView
|
||||
}
|
||||
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.swatchRows) { row in
|
||||
HStack(spacing: 2) {
|
||||
Text(row.rowId)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
ForEach(row.patches) { swatch in
|
||||
SwatchPatchView(swatch: swatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 360)
|
||||
.background(Theme.panel)
|
||||
.accessibilityIdentifier("swatchGrid")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statsView: some View {
|
||||
let patches = model.swatchRows.flatMap(\.patches)
|
||||
let valid = patches.compactMap(\.deltaE)
|
||||
let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count)
|
||||
let max = valid.max() ?? 0
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if let avg = avg {
|
||||
Text("avg ΔE \(String(format: "%.2f", avg))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("max ΔE \(String(format: "%.2f", max))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(patches.count) patches")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityIdentifier("readStats")
|
||||
}
|
||||
|
||||
// MARK: - Averaging
|
||||
|
||||
@ViewBuilder
|
||||
private var averagingSection: some View {
|
||||
if !model.passSnapshots.isEmpty || model.isFinished {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Averaging")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Text("\(model.passSnapshots.count) pass(es)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.text)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Theme.border)
|
||||
.cornerRadius(4)
|
||||
.accessibilityIdentifier("passCounterBadge")
|
||||
}
|
||||
|
||||
ForEach(model.passSnapshots, id: \.lastPathComponent) { url in
|
||||
Text(url.lastPathComponent)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityIdentifier("passesList")
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Measure Another Sheet") {
|
||||
model.measureAnotherSheet()
|
||||
}
|
||||
.disabled(!model.isFinished || model.isChartreadRunning)
|
||||
.accessibilityIdentifier("btnMeasureAnotherSheet")
|
||||
|
||||
Button("Finish & Average") {
|
||||
model.finishAndAverage()
|
||||
}
|
||||
.disabled(!model.isFinished || model.isFinishing)
|
||||
.accessibilityIdentifier("btnFinishAndAverage")
|
||||
}
|
||||
|
||||
if let notice = model.finishNotice {
|
||||
Text(notice)
|
||||
.font(.caption)
|
||||
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.panel)
|
||||
.accessibilityIdentifier("chartreadAveragingPanel")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
/// Placeholder stage surface for M1. Real stage UIs arrive in M2–M5
|
||||
/// (issues #7–#31); Stage 0 lands in M6 (issue #29).
|
||||
struct StagePlaceholderView: View {
|
||||
let stage: WizardStage
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: stage.symbolName)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text(stage.title)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.text)
|
||||
Text("This stage is not implemented yet — see the milestone plan.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Theme.background)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import ICCeryCore
|
||||
|
||||
private extension DisplayRGB {
|
||||
var color: Color {
|
||||
Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
/// One swatch in the live grid, with a 135° intended/measured diagonal split.
|
||||
struct SwatchPatchView: View {
|
||||
let swatch: Swatch
|
||||
|
||||
private var indicatorColor: Color {
|
||||
switch swatch.classification {
|
||||
case .good: return .green
|
||||
case .warning: return .yellow
|
||||
case .bad: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background: measured
|
||||
swatch.measured.color
|
||||
.clipShape(DiagonalClip(side: .bottomRight))
|
||||
|
||||
// Foreground: intended
|
||||
swatch.intended.color
|
||||
.clipShape(DiagonalClip(side: .topLeft))
|
||||
|
||||
// Classification dot
|
||||
Circle()
|
||||
.fill(indicatorColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.offset(x: 6, y: 6)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.primary.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
.accessibilityIdentifier("swatch-\(swatch.rowId)\(swatch.loc)")
|
||||
.accessibilityLabel("\(swatch.loc) intended \(String(format: "%.0f", swatch.intended.r * 255)), measured \(String(format: "%.0f", swatch.measured.r * 255))")
|
||||
}
|
||||
}
|
||||
|
||||
/// 135° diagonal clipping: top-left or bottom-right triangle.
|
||||
///
|
||||
/// A 135° line from the top-right corner to the bottom-left corner gives
|
||||
/// top-left and bottom-right triangles.
|
||||
private enum DiagonalSide {
|
||||
case topLeft
|
||||
case bottomRight
|
||||
}
|
||||
|
||||
private struct DiagonalClip: Shape {
|
||||
let side: DiagonalSide
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
switch side {
|
||||
case .topLeft:
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
case .bottomRight:
|
||||
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
}
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
|
||||
/// application (issues #7–#11).
|
||||
///
|
||||
/// `wizard` stays authoritative for persisted identity + disk gating;
|
||||
/// this model owns the editable form, logs, gallery, and preset state.
|
||||
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
|
||||
/// coalesced log batches and completion hop back.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TargetWorkflowViewModel {
|
||||
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
private let fileDialogs = FileDialogService.shared
|
||||
|
||||
// MARK: - Stage 1 form (targen)
|
||||
|
||||
var colourSpace: ColourSpace = .rgb {
|
||||
didSet {
|
||||
guard colourSpace != oldValue else { return }
|
||||
// CMYK black patches default to 0, RGB to 4 (docs/08).
|
||||
blackPatches = colourSpace == .cmyk ? 0 : 4
|
||||
}
|
||||
}
|
||||
var patchPreset: PatchCountPreset = .standard800
|
||||
/// `#patchCountCustom` — used when `patchPreset == .custom`.
|
||||
var customPatchCount = 2500
|
||||
var whitePatches = 4
|
||||
var blackPatches = 4
|
||||
|
||||
// Advanced — each optional flag is enabled + value, so an untouched
|
||||
// control emits nothing (#advanced fields are opt-in).
|
||||
var greyStepsEnabled = false
|
||||
var greySteps = 5
|
||||
var singleChannelEnabled = false
|
||||
var singleChannelSteps = 5
|
||||
var neutralStepsEnabled = false
|
||||
var neutralSteps = 3
|
||||
var neutralConcEnabled = false
|
||||
var neutralConcentration = 0.50
|
||||
var preconditioningProfile: String?
|
||||
var highQuality = false
|
||||
var adaptationEnabled = false
|
||||
var adaptation = 0.10
|
||||
var algorithm: FullSpreadAlgorithm = .ofps
|
||||
var inkLimitEnabled = false
|
||||
var totalInkLimit = 320
|
||||
var darkEmphasisEnabled = false
|
||||
var darkEmphasis = 1.0
|
||||
var devicePowerEnabled = false
|
||||
var devicePower = 1.0
|
||||
|
||||
/// `#targetBasename` — no placeholder is ever invented (#60).
|
||||
var targetBasename = ""
|
||||
/// `#selectedPathDisplay` / resolved cwd.
|
||||
var targetDirectory: URL?
|
||||
|
||||
// MARK: - Stage 2 form (printtarg)
|
||||
|
||||
var instrument: PrintInstrument = .i1
|
||||
var pageSize: PageSize = .a4
|
||||
var customPageW = 210.0
|
||||
var customPageH = 297.0
|
||||
var bitDepth: TiffBitDepth = .eight
|
||||
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
|
||||
/// regression must be visible here).
|
||||
var tiffDpi = 300
|
||||
var layoutOrder: LayoutOrder = .deterministic
|
||||
var customSeed = 1
|
||||
var labelIsCustom = false
|
||||
var customLabel = ""
|
||||
var metaPrinter = ""
|
||||
var metaInkSet = ""
|
||||
var metaDriverPaper = ""
|
||||
var metaActualPaper = ""
|
||||
|
||||
// MARK: - Run state
|
||||
|
||||
var targenRunning = false
|
||||
var targenLog: [String] = []
|
||||
var printtargRunning = false
|
||||
var printtargLog: [String] = []
|
||||
var printtargResult: PrinttargResult?
|
||||
/// Sticky until the target changes: `.ti2` resume landed us on
|
||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||
var resumedFromTi2 = false
|
||||
|
||||
// MARK: - Print panel (issue 17)
|
||||
|
||||
/// CUPS destinations from `lpstat` (#printerSelect).
|
||||
var printers: [Printer] = []
|
||||
/// Selected queue name.
|
||||
var selectedPrinter = ""
|
||||
/// Capabilities of the selected queue (#printerTraySelect /
|
||||
/// #printerMediaTypeSelect / PageSize source).
|
||||
var printerCaps = PrinterCapabilities()
|
||||
var selectedTray: Int?
|
||||
var selectedMediaType: String?
|
||||
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
|
||||
var printOrientation = "portrait"
|
||||
/// Per-queue captured `key=value` strings from Preferences — replayed
|
||||
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
|
||||
var capturedCupsOptions: [String: String] = [:]
|
||||
/// In-panel notice (#printNotification) — cancel → info, not error.
|
||||
var printNotice: String?
|
||||
var printNoticeIsError = false
|
||||
var isPrinting = false
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
var presets: [ProfilingPreset] = []
|
||||
var selectedPresetID = "none"
|
||||
var showingSavePreset = false
|
||||
var showingManagePresets = false
|
||||
var savePresetName = ""
|
||||
var savePresetDesc = ""
|
||||
|
||||
/// Stage 3 measurement workflow, owned at the app level so it persists
|
||||
/// across stage switches and can observe settings changes.
|
||||
var measurement: MeasurementWorkflowViewModel
|
||||
|
||||
init(environment: AppEnvironment = .live()) {
|
||||
self.environment = environment
|
||||
self.wizard = WizardViewModel(stateStore: environment.stateStore)
|
||||
self.measurement = MeasurementWorkflowViewModel(
|
||||
wizard: wizard,
|
||||
environment: environment
|
||||
)
|
||||
reloadPresets()
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var effectivePatchCount: Int {
|
||||
patchPreset.patchCount ?? customPatchCount
|
||||
}
|
||||
|
||||
var canGenerate: Bool {
|
||||
PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil
|
||||
}
|
||||
|
||||
var labelMetadata: TargetLabelMetadata {
|
||||
TargetLabelMetadata(
|
||||
printer: metaPrinter, inkSet: metaInkSet,
|
||||
driverPaper: metaDriverPaper, actualPaper: metaActualPaper)
|
||||
}
|
||||
|
||||
/// `#targetLabelPreview` — live preview of the automatic label.
|
||||
var automaticLabel: String {
|
||||
PrinttargLabel.automatic(
|
||||
basename: wizard.basename.isEmpty ? "target" : wizard.basename,
|
||||
metadata: labelMetadata)
|
||||
}
|
||||
|
||||
var selectedPreset: ProfilingPreset? {
|
||||
presets.first { $0.id == selectedPresetID }
|
||||
}
|
||||
|
||||
// MARK: - Stage 1: generate
|
||||
|
||||
func buildTargenConfig() -> TargenConfig {
|
||||
TargenConfig(
|
||||
colourSpace: colourSpace,
|
||||
patchCount: effectivePatchCount,
|
||||
whitePatches: whitePatches,
|
||||
blackPatches: blackPatches,
|
||||
greySteps: greyStepsEnabled ? greySteps : nil,
|
||||
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
|
||||
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
|
||||
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
|
||||
preconditioningProfile: preconditioningProfile,
|
||||
ofpsHighQuality: highQuality ? true : nil,
|
||||
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
|
||||
fullSpreadAlgorithm: algorithm == .ofps ? nil : algorithm,
|
||||
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
|
||||
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
|
||||
devicePower: devicePowerEnabled ? devicePower : nil,
|
||||
basename: targetBasename,
|
||||
workingDirectory: targetDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func browseForTargetFile() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.saveTargetURL
|
||||
: fileDialogs.selectTargetFile()
|
||||
guard let url else { return }
|
||||
targetBasename = url.deletingPathExtension().lastPathComponent
|
||||
targetDirectory = url.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
func browseForWorkingDirectory() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.workDirURL
|
||||
: fileDialogs.selectDirectory()
|
||||
if let url { targetDirectory = url }
|
||||
}
|
||||
|
||||
func browseForPreconditioningProfile() {
|
||||
if let url = fileDialogs.selectProfileFile() {
|
||||
preconditioningProfile = url.path
|
||||
}
|
||||
}
|
||||
|
||||
func generateTarget() {
|
||||
guard canGenerate, !targenRunning else { return }
|
||||
let config = buildTargenConfig()
|
||||
targenRunning = true
|
||||
targenLog = []
|
||||
resumedFromTi2 = false
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.targenLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
wizard.setTarget(
|
||||
basename: config.basename,
|
||||
workingDirectory: config.workingDirectory)
|
||||
wizard.refreshGating()
|
||||
wizard.showNotice("Target generated: \(url.lastPathComponent)")
|
||||
wizard.go(to: .layOutPrint)
|
||||
} catch {
|
||||
wizard.showNotice(
|
||||
"targen failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
targenRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Issue 8: resume an existing target
|
||||
|
||||
/// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103).
|
||||
/// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but
|
||||
/// only when the sibling `.ti1` exists so the artefact gate holds.
|
||||
func openExistingTarget() {
|
||||
let url = UITestHooks.isEnabled
|
||||
? UITestHooks.existingTargetURL
|
||||
: fileDialogs.selectExistingTarget()
|
||||
guard let url else { return }
|
||||
|
||||
let stem = url.deletingPathExtension().lastPathComponent
|
||||
let dir = url.deletingLastPathComponent()
|
||||
guard PathSecurity.isValidBasename(stem) else {
|
||||
wizard.showNotice("Invalid target name.", kind: .error)
|
||||
return
|
||||
}
|
||||
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "ti1":
|
||||
wizard.setTarget(basename: stem, workingDirectory: dir)
|
||||
wizard.refreshGating()
|
||||
resumedFromTi2 = false
|
||||
measurement.resumedFromTi2 = false
|
||||
wizard.go(to: .layOutPrint)
|
||||
case "ti2":
|
||||
let header = Ti2Header.parse(url)
|
||||
guard header.hasSiblingTi1 else {
|
||||
wizard.showNotice(
|
||||
"Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.",
|
||||
kind: .error)
|
||||
return
|
||||
}
|
||||
wizard.setTarget(basename: stem, workingDirectory: dir)
|
||||
wizard.refreshGating()
|
||||
resumedFromTi2 = true
|
||||
measurement.resumedFromTi2 = true
|
||||
wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil)
|
||||
wizard.go(to: .measure)
|
||||
default:
|
||||
wizard.showNotice(
|
||||
"Not a target file — choose a .ti1 or .ti2.", kind: .error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stage 2: create layout
|
||||
|
||||
func buildPrinttargConfig() -> PrinttargConfig {
|
||||
PrinttargConfig(
|
||||
instrument: instrument,
|
||||
pageSize: pageSize,
|
||||
customPageWidth: customPageW,
|
||||
customPageHeight: customPageH,
|
||||
bitDepth: bitDepth,
|
||||
dpi: tiffDpi,
|
||||
layoutOrder: layoutOrder,
|
||||
customSeed: customSeed,
|
||||
label: PrinttargLabel.resolved(
|
||||
customLabel: labelIsCustom ? customLabel : nil,
|
||||
basename: wizard.basename,
|
||||
metadata: labelMetadata),
|
||||
basename: wizard.basename,
|
||||
workingDirectory: wizard.effectiveWorkingDirectory
|
||||
)
|
||||
}
|
||||
|
||||
func createLayout() {
|
||||
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
|
||||
let config = buildPrinttargConfig()
|
||||
printtargRunning = true
|
||||
printtargLog = []
|
||||
printtargResult = nil
|
||||
let runner = environment.runner
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.printtargLog.append(contentsOf: batch)
|
||||
}
|
||||
}
|
||||
printtargResult = result
|
||||
wizard.refreshGating()
|
||||
wizard.showNotice(
|
||||
"Layout created — \(result.manifest.pages.count) page(s) ready.")
|
||||
} catch {
|
||||
// Stay on Stage 2: non-zero exit, malformed manifest, or
|
||||
// missing .ti2 must never advance the wizard (#156).
|
||||
wizard.showNotice(
|
||||
"printtarg failed: \(error.localizedDescription)", kind: .error)
|
||||
}
|
||||
printtargRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnAdvanceToStage3` — manual advance once `.ti2` exists.
|
||||
func advanceToStage3() {
|
||||
wizard.refreshGating()
|
||||
wizard.go(to: .measure)
|
||||
}
|
||||
|
||||
// MARK: - Print panel actions (issue 17)
|
||||
|
||||
/// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load
|
||||
/// capabilities for the selection. Auto-runs when the panel first
|
||||
/// appears with a manifest.
|
||||
func refreshPrinters() {
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let list = try await cups.listPrinters()
|
||||
printers = list
|
||||
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||
selectedPrinter = list.first { $0.isDefault }?.name
|
||||
?? list.first?.name ?? ""
|
||||
}
|
||||
await reloadSelectedCapabilities()
|
||||
} catch {
|
||||
printNotice = "Could not list printers: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities for `selectedPrinter` — trays / media / sizes feed
|
||||
/// the selects.
|
||||
func reloadSelectedCapabilities() async {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
printerCaps = PrinterCapabilities()
|
||||
return
|
||||
}
|
||||
do {
|
||||
printerCaps = try await environment.cupsService
|
||||
.capabilities(for: selectedPrinter)
|
||||
// Default selections only when the captured options didn't
|
||||
// already pin them (Preferences round-trip wins).
|
||||
if selectedMediaType == nil {
|
||||
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||
}
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings").
|
||||
/// Cancel → info notice, never an error, cache untouched. On OK the
|
||||
/// captured options are stored per-queue; a panel-side queue switch
|
||||
/// updates `printerSelect` when the returned CUPS id is in the list.
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
else {
|
||||
printNotice = "Printer properties dialog cancelled."
|
||||
printNoticeIsError = false
|
||||
return
|
||||
}
|
||||
if let selected = result.selectedPrinter,
|
||||
printers.contains(where: { $0.name == selected }),
|
||||
selected != queue {
|
||||
selectedPrinter = selected
|
||||
await reloadSelectedCapabilities()
|
||||
}
|
||||
if let captured = result.options.cupsOptions {
|
||||
capturedCupsOptions[selectedPrinter] = captured
|
||||
}
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
printNotice = "Settings captured for \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
||||
/// the first failure so the user sees which page failed.
|
||||
func printAllPages() {
|
||||
guard let result = printtargResult, !isPrinting else { return }
|
||||
isPrinting = true
|
||||
Task { @MainActor in
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = "Print failed on \(page.page.filename): "
|
||||
+ error.localizedDescription
|
||||
printNoticeIsError = true
|
||||
isPrinting = false
|
||||
return
|
||||
}
|
||||
}
|
||||
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
isPrinting = false
|
||||
}
|
||||
}
|
||||
|
||||
/// `#btnPrintPage-N` — one TIFF.
|
||||
func printPage(_ page: GalleryPage) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await spool(page, index: page.index)
|
||||
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
||||
printNoticeIsError = false
|
||||
} catch {
|
||||
printNotice = "Print failed: \(error.localizedDescription)"
|
||||
printNoticeIsError = true
|
||||
}
|
||||
isPrinting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
mediaType: selectedMediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
queue: selectedPrinter,
|
||||
tiffPath: page.fileURL.path,
|
||||
options: options,
|
||||
page: index)
|
||||
// For Stage 5 history (#95): record which queue printed.
|
||||
wizard.printerName = selectedPrinter
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
func reloadPresets() {
|
||||
presets = environment.presetStore.all()
|
||||
}
|
||||
|
||||
/// Applies every Stage 1/2 field of the preset to the live form
|
||||
/// (bidirectional — the draft preset's dpi=150 must be visible).
|
||||
func applyPreset(_ preset: ProfilingPreset) {
|
||||
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
|
||||
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
|
||||
customPatchCount = preset.patchCount
|
||||
whitePatches = preset.whitePatches
|
||||
blackPatches = preset.blackPatches
|
||||
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
|
||||
singleChannelSteps = preset.singleChannelSteps ?? 5
|
||||
singleChannelEnabled = preset.singleChannelSteps != nil
|
||||
neutralSteps = preset.neutralSteps ?? 3
|
||||
neutralStepsEnabled = preset.neutralSteps != nil
|
||||
neutralConcentration = preset.neutralConcentration ?? 0.50
|
||||
neutralConcEnabled = preset.neutralConcentration != nil
|
||||
preconditioningProfile = preset.preconditioningProfile
|
||||
highQuality = preset.ofpsHighQuality == true
|
||||
adaptation = preset.ofpsAdaptation ?? 0.10
|
||||
adaptationEnabled = preset.ofpsAdaptation != nil
|
||||
algorithm = preset.fullSpreadAlgorithm
|
||||
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
|
||||
totalInkLimit = preset.totalInkLimit ?? 320
|
||||
inkLimitEnabled = preset.totalInkLimit != nil
|
||||
darkEmphasis = preset.darkEmphasis ?? 1.0
|
||||
darkEmphasisEnabled = preset.darkEmphasis != nil
|
||||
devicePower = preset.devicePower ?? 1.0
|
||||
devicePowerEnabled = preset.devicePower != nil
|
||||
|
||||
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
|
||||
if let size = PageSize(rawValue: preset.pageSize) {
|
||||
pageSize = size
|
||||
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
|
||||
pageSize = .custom; customPageW = w; customPageH = h
|
||||
} else {
|
||||
pageSize = .a4
|
||||
}
|
||||
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
|
||||
tiffDpi = preset.dpi
|
||||
if preset.noRandomize == true {
|
||||
layoutOrder = .raster
|
||||
} else if (preset.randomSeed ?? 1) == 1 {
|
||||
layoutOrder = .deterministic
|
||||
} else {
|
||||
layoutOrder = .customSeed
|
||||
}
|
||||
customSeed = preset.randomSeed ?? 1
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens).
|
||||
enum Theme {
|
||||
static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255)
|
||||
static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255)
|
||||
static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255)
|
||||
static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255)
|
||||
static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255)
|
||||
/// v1 window/titlebar backing colour (docs/02 §Window contract).
|
||||
static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255)
|
||||
|
||||
enum Metrics {
|
||||
static let sidebarWidth: CGFloat = 270
|
||||
static let buttonSmall: CGFloat = 28
|
||||
static let buttonMedium: CGFloat = 36
|
||||
static let buttonLarge: CGFloat = 40
|
||||
static let cornerSmall: CGFloat = 4
|
||||
static let cornerMedium: CGFloat = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ICCeryCore
|
||||
|
||||
/// 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 {
|
||||
|
||||
// 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()
|
||||
|
||||
private let stateStore: WizardStateStore
|
||||
private var noticeDismissTask: Task<Void, Never>?
|
||||
|
||||
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 }
|
||||
|
||||
/// 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)
|
||||
self.notice = notice
|
||||
if let delay = notice.autoHideAfter {
|
||||
noticeDismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
if self?.notice?.id == notice.id {
|
||||
self?.notice = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismissNotice() {
|
||||
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,27 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("AppPaths")
|
||||
struct AppPathsTests {
|
||||
@Test func appDataDirUsesBundleID() {
|
||||
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func logFileIsUnderLibraryLogs() {
|
||||
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
|
||||
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
|
||||
}
|
||||
|
||||
@Test func bundledArgyllDirIsInsideResources() {
|
||||
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("WizardStage")
|
||||
struct WizardStageTests {
|
||||
@Test func stepperOrderIsOneThroughFive() {
|
||||
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
|
||||
#expect(WizardStage.calibrate.stepperIndex == nil)
|
||||
}
|
||||
}
|
||||
@@ -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,296 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ICCeryCore
|
||||
|
||||
@Suite("InstrumentParser")
|
||||
struct InstrumentParserTests {
|
||||
|
||||
@Test("Parses pretty-printed instlist JSON")
|
||||
func json() throws {
|
||||
let json = """
|
||||
{
|
||||
"event": "instruments",
|
||||
"devices": [
|
||||
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
|
||||
{"port": 2, "name": "i1Pro 2", "type": "usb"},
|
||||
{"port": 3, "name": "i1iO Table", "type": "usb"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let devices = try InstrumentParser.parse(json)
|
||||
#expect(devices.count == 3)
|
||||
#expect(devices[0].port == 1)
|
||||
#expect(devices[0].name == "X-Rite i1Pro")
|
||||
#expect(devices[2].port == 3)
|
||||
}
|
||||
|
||||
@Test("Falls back to regex for legacy instlist text")
|
||||
func regexFallback() throws {
|
||||
let text = """
|
||||
1: 'X-Rite i1Pro' on usb
|
||||
2: 'ColorMunki Smile'
|
||||
""" + "\n"
|
||||
let devices = try InstrumentParser.parse(text)
|
||||
#expect(devices.count == 2)
|
||||
#expect(devices[0].port == 1)
|
||||
#expect(devices[1].name == "ColorMunki Smile")
|
||||
}
|
||||
|
||||
@Test("Empty output returns no devices")
|
||||
func empty() throws {
|
||||
#expect(try InstrumentParser.parse("").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadArgs")
|
||||
struct ChartreadArgsTests {
|
||||
|
||||
@Test("Baseline argv and port 1 omits -c")
|
||||
func baseline() throws {
|
||||
let config = ChartreadConfig(basename: "target", selectedPort: 1)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args == ["-v", "-u", "target"])
|
||||
}
|
||||
|
||||
@Test("Port > 1 emits -c")
|
||||
func portArgument() throws {
|
||||
let config = ChartreadConfig(basename: "target", selectedPort: 3)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args == ["-v", "-u", "-c", "3", "target"])
|
||||
}
|
||||
|
||||
@Test("LEDs emit -Y l")
|
||||
func leds() throws {
|
||||
let config = ChartreadConfig(
|
||||
basename: "target",
|
||||
selectedPort: 2,
|
||||
enableLEDs: true
|
||||
)
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(args.contains("-Y"))
|
||||
#expect(args.contains("l"))
|
||||
}
|
||||
|
||||
@Test("Auto omits -c")
|
||||
func autoPort() throws {
|
||||
let config = ChartreadConfig(basename: "target")
|
||||
let args = try ChartreadArgs.build(config: config)
|
||||
#expect(!args.contains("-c"))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadClassifier")
|
||||
struct ChartreadClassifierTests {
|
||||
|
||||
@Test("Calibration prompt")
|
||||
func calibration() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
|
||||
previousState: .idle
|
||||
)
|
||||
#expect(r.state == .calibrating)
|
||||
}
|
||||
|
||||
@Test("Strip awaiting")
|
||||
func awaitingStrip() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Hit [Space] to read strip A",
|
||||
previousState: .calibrating
|
||||
)
|
||||
#expect(r.state == .awaitingStrip)
|
||||
}
|
||||
|
||||
@Test("Done prompt")
|
||||
func done() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "'d' if/when done",
|
||||
previousState: .awaitingStrip
|
||||
)
|
||||
#expect(r.state == .allStripsRead)
|
||||
}
|
||||
|
||||
@Test("XY place sheet")
|
||||
func placeSheet() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Please place sheet 1 of 2 on the table",
|
||||
previousState: .idle
|
||||
)
|
||||
#expect(r.state == .tablePlaceSheet)
|
||||
#expect(r.sheetNumber == 1)
|
||||
#expect(r.sheetTotal == 2)
|
||||
}
|
||||
|
||||
@Test("XY locate patch")
|
||||
func locatePatch() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "locate patch A1 with the sight,",
|
||||
previousState: .tablePlaceSheet
|
||||
)
|
||||
#expect(r.state == .tableAlign)
|
||||
#expect(r.alignmentPatch == "A1")
|
||||
}
|
||||
|
||||
@Test("Remove sheet notice preserves state")
|
||||
func removeNotice() {
|
||||
let r = ChartreadClassifier.classify(
|
||||
line: "Please remove last sheet from table",
|
||||
previousState: .tablePlaceSheet
|
||||
)
|
||||
#expect(r.state == .tablePlaceSheet)
|
||||
#expect(r.isRemoveSheetNotice == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ChartreadRow")
|
||||
struct ChartreadRowTests {
|
||||
|
||||
@Test("Decodes row JSON")
|
||||
func decode() throws {
|
||||
let json = """
|
||||
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
|
||||
"patch_count": 1, "patches": [
|
||||
{"id": "1", "loc": "A1", "is_pad": false, "device": [0, 50, 100],
|
||||
"expected": {"Lab": [50, 0, 0]},
|
||||
"measured": {"Lab": [51, 1, -1]}}
|
||||
]}
|
||||
"""
|
||||
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
|
||||
#expect(row.rowId == "A")
|
||||
#expect(row.patchCount == 1)
|
||||
#expect(row.patches[0].measured.lab?.l == 51)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ColourMath")
|
||||
struct ColourMathTests {
|
||||
|
||||
@Test("White XYZ to Lab")
|
||||
func whiteLab() {
|
||||
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
|
||||
let lab = LabColorMath.xyzToLab(white)
|
||||
#expect(abs(lab.l - 100) < 0.5)
|
||||
#expect(abs(lab.a) < 0.5)
|
||||
#expect(abs(lab.b) < 0.5)
|
||||
}
|
||||
|
||||
@Test("Lab to sRGB roundtrip is clamped")
|
||||
func labToSRGB() {
|
||||
let red = LabColor(l: 55, a: 80, b: 70)
|
||||
let rgb = LabColorMath.labToSRGB(red)
|
||||
#expect(rgb.r > 0.8)
|
||||
#expect(rgb.g < 0.2)
|
||||
#expect(rgb.b < 0.2)
|
||||
}
|
||||
|
||||
@Test("Pad white returns DisplayRGB")
|
||||
func padWhite() {
|
||||
let white = LabColor(l: 95, a: 0, b: 0)
|
||||
let rgb = LabColorMath.labToSRGB(white)
|
||||
#expect(rgb.r > 0.9)
|
||||
#expect(rgb.g > 0.9)
|
||||
#expect(rgb.b > 0.9)
|
||||
}
|
||||
|
||||
@Test("Standard CIEDE2000 vector (Sharma)")
|
||||
func ciede2000() {
|
||||
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
|
||||
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
|
||||
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Classification respects thresholds")
|
||||
func classify() {
|
||||
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
|
||||
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
|
||||
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("MeasurementArtefacts")
|
||||
struct MeasurementArtefactTests {
|
||||
|
||||
private func makeCwd() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("Discovers passes in order")
|
||||
func discovery() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
try "A".write(to: cwd.appendingPathComponent("target_pass3.ti3"), atomically: true, encoding: .utf8)
|
||||
try "B".write(to: cwd.appendingPathComponent("target_pass1.ti3"), atomically: true, encoding: .utf8)
|
||||
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
|
||||
|
||||
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
|
||||
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
|
||||
}
|
||||
|
||||
@Test("Snapshot and promote are atomic")
|
||||
func snapshotPromote() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
let canonical = cwd.appendingPathComponent("target.ti3")
|
||||
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
|
||||
|
||||
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||
#expect(pass.lastPathComponent == "target_pass1.ti3")
|
||||
#expect(!FileManager.default.fileExists(atPath: canonical.path))
|
||||
|
||||
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
|
||||
#expect(promoted.lastPathComponent == "target.ti3")
|
||||
#expect(FileManager.default.fileExists(atPath: promoted.path))
|
||||
}
|
||||
|
||||
@Test("Pass collisions handled")
|
||||
func collision() throws {
|
||||
let cwd = try makeCwd()
|
||||
defer { try? FileManager.default.removeItem(at: cwd) }
|
||||
|
||||
let canonical = cwd.appendingPathComponent("target.ti3")
|
||||
try "v1".write(to: canonical, atomically: true, encoding: .utf8)
|
||||
_ = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||
|
||||
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
|
||||
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
|
||||
#expect(pass2.lastPathComponent == "target_pass2.ti3")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("AverageArgs")
|
||||
struct AverageArgsTests {
|
||||
|
||||
@Test("Requires at least two pass files")
|
||||
func passCount() {
|
||||
let cwd = URL(fileURLWithPath: "/tmp")
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
basename: "target",
|
||||
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
|
||||
)
|
||||
#expect(throws: AverageArgError.self) {
|
||||
_ = try AverageArgs.build(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Output is last and inputs are relative")
|
||||
func ordering() throws {
|
||||
let cwd = URL(fileURLWithPath: "/tmp")
|
||||
let config = AverageConfig(
|
||||
workingDirectory: cwd,
|
||||
basename: "target",
|
||||
passFiles: [
|
||||
URL(fileURLWithPath: "/tmp/target_pass1.ti3"),
|
||||
URL(fileURLWithPath: "/tmp/target_pass2.ti3"),
|
||||
]
|
||||
)
|
||||
let args = try AverageArgs.build(config: config)
|
||||
#expect(args.first == "-v")
|
||||
#expect(args.last == "target.ti3")
|
||||
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
|
||||
}
|
||||
}
|
||||
@@ -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,262 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
||||
/// scripts written to a temp dir — no resource bundling required.
|
||||
@Suite("ProcessManager", .serialized)
|
||||
struct ProcessManagerTests {
|
||||
|
||||
// MARK: - Fixture plumbing
|
||||
|
||||
private static let fixtureDir: URL = {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
|
||||
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}()
|
||||
|
||||
/// Writes a shell script fixture and returns its executable URL.
|
||||
private func script(_ name: String, _ body: String) throws -> URL {
|
||||
let url = Self.fixtureDir.appendingPathComponent(name)
|
||||
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
||||
)
|
||||
return url
|
||||
}
|
||||
|
||||
/// Collects events for `id` until `.exit`, `timeout` seconds max.
|
||||
private func collect(
|
||||
_ manager: ProcessManager,
|
||||
id: String,
|
||||
timeout: TimeInterval = 10
|
||||
) async -> [ProcessEvent] {
|
||||
await withCheckedContinuation { cont in
|
||||
let box = Box()
|
||||
Task {
|
||||
for await event in manager.events() {
|
||||
guard event.id == id else { continue }
|
||||
box.append(event)
|
||||
if case .exit = event { break }
|
||||
}
|
||||
if box.finish() { cont.resume(returning: box.events) }
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
if box.finish() { cont.resume(returning: box.events) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class Box: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _events: [ProcessEvent] = []
|
||||
private var finished = false
|
||||
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
|
||||
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
|
||||
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func streamsStdoutAndEmitsExit() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
||||
async let events = collect(pm, id: "t1")
|
||||
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
|
||||
let evs = await events
|
||||
let lines = evs.compactMap { e -> String? in
|
||||
if case .stdout(_, let l) = e { return l }; return nil
|
||||
}
|
||||
#expect(lines == ["hello", "world"])
|
||||
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
||||
}
|
||||
|
||||
@Test func routesStderrSeparately() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
||||
async let evs = collect(pm, id: "t2")
|
||||
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
||||
let events = await evs
|
||||
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
||||
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
||||
}
|
||||
|
||||
@Test func stripsRowColorsJSONPrefix() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script(
|
||||
"rows.sh",
|
||||
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
|
||||
)
|
||||
async let evs = collect(pm, id: "t3")
|
||||
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
|
||||
let events = await evs
|
||||
let rows = events.compactMap { e -> String? in
|
||||
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
||||
return nil
|
||||
}
|
||||
#expect(rows == ["{\"row\":1}"])
|
||||
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
||||
// Prefixed lines must not leak into stdout.
|
||||
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
||||
}
|
||||
|
||||
@Test func unterminatedTailFlushesOnExit() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
||||
async let evs = collect(pm, id: "t4")
|
||||
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
||||
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
||||
}
|
||||
|
||||
@Test func stdinRoundTrip() async throws {
|
||||
let pm = ProcessManager()
|
||||
// Read two lines then exit naturally — a killed sh would lose its
|
||||
// buffered stdio output, which is exactly the chartread pattern.
|
||||
let bin = try script(
|
||||
"echo.sh",
|
||||
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
|
||||
)
|
||||
async let evs = collect(pm, id: "t5")
|
||||
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
|
||||
try await pm.sendStdin(id: "t5", text: " \n")
|
||||
try await pm.sendStdin(id: "t5", text: "d\n")
|
||||
let events = await evs
|
||||
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
||||
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
||||
}
|
||||
|
||||
@Test func duplicateIDRejected() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||
await #expect(throws: ProcessError.duplicateID("t6")) {
|
||||
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
||||
}
|
||||
await pm.kill(id: "t6")
|
||||
}
|
||||
|
||||
@Test func killEmitsExitAndClosesStdin() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
||||
async let evs = collect(pm, id: "t7")
|
||||
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
|
||||
await pm.kill(id: "t7")
|
||||
let events = await evs
|
||||
// exit emitted exactly once
|
||||
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
||||
#expect(exits.count == 1)
|
||||
await #expect(throws: ProcessError.unknownID("t7")) {
|
||||
try await pm.sendStdin(id: "t7", text: "d\n")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func killAllCountsSignaled() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
||||
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
||||
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
||||
let count = await pm.killAll()
|
||||
#expect(count == 2)
|
||||
}
|
||||
|
||||
@Test func capturedRunReturnsBothStreams() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
||||
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
||||
#expect(result.stdout.contains("out-data"))
|
||||
#expect(result.stderr.contains("err-data"))
|
||||
#expect(result.exitCode == 3)
|
||||
}
|
||||
|
||||
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
||||
let pm = ProcessManager()
|
||||
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
||||
let bin = try script(
|
||||
"big.sh",
|
||||
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
||||
)
|
||||
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
||||
#expect(result.stdout.contains("out-4999"))
|
||||
#expect(result.stderr.contains("err-4999"))
|
||||
}
|
||||
|
||||
@Test func argyllEnvVarIsSet() async throws {
|
||||
let pm = ProcessManager()
|
||||
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
||||
async let evs = collect(pm, id: "t10")
|
||||
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
||||
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
||||
}
|
||||
|
||||
@Test func unknownIDStdinThrows() async throws {
|
||||
let pm = ProcessManager()
|
||||
await #expect(throws: ProcessError.unknownID("nope")) {
|
||||
try await pm.sendStdin(id: "nope", text: "d\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ProcessLineDecoder")
|
||||
struct ProcessLineDecoderTests {
|
||||
@Test func splitsAcrossChunkBoundaries() {
|
||||
var d = ProcessLineDecoder()
|
||||
#expect(d.feed(Data("he".utf8)) == [])
|
||||
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
|
||||
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
|
||||
#expect(d.finish() == nil)
|
||||
}
|
||||
|
||||
@Test func crlfIsStripped() {
|
||||
var d = ProcessLineDecoder()
|
||||
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
|
||||
}
|
||||
|
||||
@Test func finishReturnsRemainder() {
|
||||
var d = ProcessLineDecoder()
|
||||
_ = d.feed(Data("x".utf8))
|
||||
#expect(d.finish() == "x")
|
||||
#expect(d.finish() == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("JSONAccumulator")
|
||||
struct JSONAccumulatorTests {
|
||||
@Test func multilinePrettyJSON() {
|
||||
var acc = JSONAccumulator()
|
||||
#expect(acc.feed(line: "{") == nil)
|
||||
#expect(acc.feed(line: " \"k\": 1") == nil)
|
||||
let done = acc.feed(line: "}")
|
||||
#expect(done != nil)
|
||||
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
||||
#expect(obj?["k"] == 1)
|
||||
}
|
||||
|
||||
@Test func nonJSONLinesIgnored() {
|
||||
var acc = JSONAccumulator()
|
||||
#expect(acc.feed(line: "Reading instrument...") == nil)
|
||||
#expect(acc.feed(line: "still text") == nil)
|
||||
#expect(acc.completeData == nil)
|
||||
}
|
||||
|
||||
@Test func decodeTyped() {
|
||||
struct Doc: Decodable { let n: Int }
|
||||
var acc = JSONAccumulator()
|
||||
// Split so the doc completes on the second feed.
|
||||
#expect(acc.feed(line: "{\"n\":") == nil)
|
||||
let data = acc.feed(line: "7}")
|
||||
#expect(data != nil)
|
||||
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
||||
#expect(doc?.n == 7)
|
||||
#expect(acc.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("LogSanitizer")
|
||||
struct LogSanitizerTests {
|
||||
@Test func homeIsRewritten() {
|
||||
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
||||
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
|
||||
}
|
||||
}
|
||||
@@ -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
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Mock average for Milestone4UITests.
|
||||
# Usage: average -v pass1.ti3 pass2.ti3 ... output.ti3
|
||||
# The canonical output is the last argument.
|
||||
# Set MOCK_AVERAGE_FAIL=1 to exit with code 1.
|
||||
if [ "${MOCK_AVERAGE_FAIL:-0}" -ne 0 ]; then
|
||||
echo "average: could not converge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Drop leading -v
|
||||
shift
|
||||
|
||||
output="$1"
|
||||
if [ $# -ge 2 ]; then
|
||||
output="$2"
|
||||
fi
|
||||
|
||||
# Find last argument
|
||||
for arg in "$@"; do
|
||||
output="$arg"
|
||||
done
|
||||
|
||||
# Sanity: the output is the last argument.
|
||||
# Write a fake canonical .ti3 that identifies the inputs.
|
||||
{
|
||||
echo "CTI3"
|
||||
echo "INPUTS:"
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" != "$output" ]; then
|
||||
echo "$arg"
|
||||
fi
|
||||
done
|
||||
} > "$output"
|
||||
exit 0
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mock chartread for Milestone4UITests.
|
||||
|
||||
Supports handheld (MOCK_CHARTREAD_MODE=strip) and XY (MOCK_CHARTREAD_MODE=xy).
|
||||
Writes basename.ti3 on receiving 'd' and exits 0.
|
||||
Exit 0 and no .ti3 on 'q' before done.
|
||||
Usage: chartread -v -u [-c port] [-Y l] basename
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def read_line():
|
||||
try:
|
||||
return sys.stdin.readline()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def emit_row(payload: dict):
|
||||
text = "ROW_COLORS_JSON: " + json.dumps(payload)
|
||||
print(text, flush=True)
|
||||
|
||||
|
||||
def write_ti3(basename: str):
|
||||
if basename:
|
||||
with open(f"{basename}.ti3", "w") as f:
|
||||
f.write("MOCK_TI3\n")
|
||||
|
||||
|
||||
def main():
|
||||
mode = os.environ.get("MOCK_CHARTREAD_MODE", "strip")
|
||||
basename = ""
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.startswith("-"):
|
||||
continue
|
||||
basename = arg
|
||||
|
||||
def read_input():
|
||||
line = read_line()
|
||||
if not line:
|
||||
sys.exit(1)
|
||||
return line.strip()
|
||||
|
||||
if mode == "xy":
|
||||
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
|
||||
read_input()
|
||||
print("Calibration successful.", flush=True)
|
||||
|
||||
print("Please place sheet 1 of 1 on the table", flush=True)
|
||||
print("hit return to continue, Esc or 'q' to give up", flush=True)
|
||||
read_input()
|
||||
|
||||
print("locate patch A1 with the sight,", flush=True)
|
||||
print("then hit return to continue", flush=True)
|
||||
read_input()
|
||||
|
||||
print("Reading sheet 1...", flush=True)
|
||||
emit_row({
|
||||
"event": "row_complete",
|
||||
"row_id": "A",
|
||||
"row_index": 0,
|
||||
"total_rows": 1,
|
||||
"patch_count": 3,
|
||||
"patches": [
|
||||
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
|
||||
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
|
||||
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
|
||||
]
|
||||
})
|
||||
|
||||
print("Sheet 1 of 1 read OK", flush=True)
|
||||
print("Please remove last sheet from table", flush=True)
|
||||
print("'d' if/when done", flush=True)
|
||||
while True:
|
||||
line = read_input()
|
||||
if line.startswith("d"):
|
||||
write_ti3(basename)
|
||||
sys.exit(0)
|
||||
if line.startswith("q"):
|
||||
sys.exit(0)
|
||||
|
||||
# Handheld / strip mode (default)
|
||||
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
|
||||
read_input()
|
||||
print("Calibration successful.", flush=True)
|
||||
|
||||
print("Hit [Space] to read strip A", flush=True)
|
||||
read_input()
|
||||
print("Reading strip A...", flush=True)
|
||||
emit_row({
|
||||
"event": "row_complete",
|
||||
"row_id": "A",
|
||||
"row_index": 0,
|
||||
"total_rows": 2,
|
||||
"patch_count": 3,
|
||||
"patches": [
|
||||
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
|
||||
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
|
||||
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
|
||||
]
|
||||
})
|
||||
|
||||
print("Hit [Space] to read strip B", flush=True)
|
||||
read_input()
|
||||
print("Reading strip B...", flush=True)
|
||||
emit_row({
|
||||
"event": "row_complete",
|
||||
"row_id": "B",
|
||||
"row_index": 1,
|
||||
"total_rows": 2,
|
||||
"patch_count": 2,
|
||||
"patches": [
|
||||
{"id": "4", "loc": "B1", "is_pad": False, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}},
|
||||
{"id": "5", "loc": "B2", "is_pad": False, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}},
|
||||
]
|
||||
})
|
||||
|
||||
print("'d' if/when done", flush=True)
|
||||
while True:
|
||||
line = read_input()
|
||||
if line.startswith("d"):
|
||||
write_ti3(basename)
|
||||
sys.exit(0)
|
||||
if line.startswith("q"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Mock instlist for Milestone4UITests. Emits a pretty JSON device list.
|
||||
# Override the list with ICCERY_MOCK_INSTLIST_JSON.
|
||||
if [ -n "${ICCERY_MOCK_INSTLIST_JSON}" ]; then
|
||||
echo "${ICCERY_MOCK_INSTLIST_JSON}"
|
||||
exit 0
|
||||
fi
|
||||
printf '{
|
||||
"event": "instruments",
|
||||
"devices": [
|
||||
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
|
||||
{"port": 2, "name": "X-Rite i1Pro 2", "type": "usb"},
|
||||
{"port": 3, "name": "i1iO Table", "type": "usb"}
|
||||
]
|
||||
}
|
||||
'
|
||||
exit 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user