Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563f0e5d4a | ||
|
|
cd4665e7a9 | ||
|
|
153b6194a3 | ||
|
|
f78da50a59 | ||
|
|
629a1fce1d | ||
|
|
460b0a1ffa | ||
|
|
ef56cdd7d4 | ||
|
|
61c6d62ee2 | ||
|
|
f769cf7fec | ||
|
|
da53feec7f | ||
|
|
64393b7591 | ||
|
|
cc184bfff3 | ||
|
|
bb6ca957ba | ||
|
|
ad6f8247d2 | ||
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 | ||
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
14f521a65e | ||
|
|
4281d07754 | ||
|
|
71172d751a | ||
|
|
73db1c8c25 | ||
|
|
7c1303ac11 | ||
|
|
797d30b023 | ||
|
|
65ba6dc61a | ||
|
|
c0f5fb8c28 | ||
|
|
dc62f5c016 | ||
|
|
7385cf1640 | ||
|
|
ea9409ddd4 | ||
|
|
ee16fb3fae | ||
|
|
933eadd1c3 | ||
|
|
2a608c8962 | ||
|
|
3d206e27c1 | ||
|
|
552227c3af | ||
|
|
5ed3ff5428 | ||
|
|
6b122b2cbc | ||
|
|
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,800 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `ArgyllRunner` executions.
|
||||||
|
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
|
||||||
|
case processFailed(code: Int32, logs: [String])
|
||||||
|
case missingArtefact(String)
|
||||||
|
case malformedManifest(String)
|
||||||
|
case instrumentDetectionFailed(String)
|
||||||
|
case chartreadFailed(String)
|
||||||
|
case averageFailed(String)
|
||||||
|
case colprofFailed(String)
|
||||||
|
case applycalFailed(String)
|
||||||
|
case iccgamutFailed(String)
|
||||||
|
case profcheckFailed(String)
|
||||||
|
case profcheckUnparseable
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .processFailed(let code, _):
|
||||||
|
return "Process exited with code \(code)"
|
||||||
|
case .missingArtefact(let path):
|
||||||
|
return "Expected output file was not created: \(path)"
|
||||||
|
case .malformedManifest(let reason):
|
||||||
|
return "Failed to parse printtarg manifest: \(reason)"
|
||||||
|
case .instrumentDetectionFailed(let reason):
|
||||||
|
return "Instrument detection failed: \(reason)"
|
||||||
|
case .chartreadFailed(let reason):
|
||||||
|
return "Chartread failed: \(reason)"
|
||||||
|
case .averageFailed(let reason):
|
||||||
|
return "Averaging failed: \(reason)"
|
||||||
|
case .colprofFailed(let reason):
|
||||||
|
return "Profile creation failed: \(reason)"
|
||||||
|
case .applycalFailed(let reason):
|
||||||
|
return "Apply calibration failed: \(reason)"
|
||||||
|
case .iccgamutFailed(let reason):
|
||||||
|
return "Gamut extraction failed: \(reason)"
|
||||||
|
case .profcheckFailed(let reason):
|
||||||
|
return "Profile verification failed: \(reason)"
|
||||||
|
case .profcheckUnparseable:
|
||||||
|
return "Profile verification produced unparseable output"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful `printtarg` run: the `.ti2` artefact plus the
|
||||||
|
/// validated manifest with per-page PNG previews already decoded.
|
||||||
|
public struct PrinttargResult: Sendable, Equatable {
|
||||||
|
public let ti2URL: URL
|
||||||
|
public let manifest: PrinttargManifest
|
||||||
|
public let pages: [GalleryPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service driving Argyll subprocesses off the main actor
|
||||||
|
/// (docs/03, docs/08, docs/09).
|
||||||
|
///
|
||||||
|
/// - Subscribes to the event bus *before* spawning so no stdout or exit
|
||||||
|
/// is ever lost (subscription is synchronous in `ProcessManager`).
|
||||||
|
/// - Accumulates stdout/stderr without touching `@MainActor`; the
|
||||||
|
/// optional `onLogBatch` callback receives coalesced chunks (20 lines
|
||||||
|
/// or ~100 ms), never one call per line.
|
||||||
|
/// - Exit code 0 is necessary but not sufficient: the expected artefact
|
||||||
|
/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a
|
||||||
|
/// valid `-u` manifest.
|
||||||
|
public struct ArgyllRunner: Sendable {
|
||||||
|
public let processManager: ProcessManager
|
||||||
|
public let binaryResolver: BinaryResolver
|
||||||
|
|
||||||
|
public init(
|
||||||
|
processManager: ProcessManager = .shared,
|
||||||
|
binaryResolver: BinaryResolver = BinaryResolver()
|
||||||
|
) {
|
||||||
|
self.processManager = processManager
|
||||||
|
self.binaryResolver = binaryResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - targen (Stage 1)
|
||||||
|
|
||||||
|
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
|
||||||
|
/// upon completion. Returns the `.ti1` URL.
|
||||||
|
public func runTargen(
|
||||||
|
config: TargenConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try TargenArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("targen")
|
||||||
|
let processId = ProcessID.targen(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||||
|
}
|
||||||
|
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
|
||||||
|
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
|
||||||
|
}
|
||||||
|
return ti1URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - printtarg (Stage 2)
|
||||||
|
|
||||||
|
/// Runs `printtarg` streaming, then parses the `-u` manifest from
|
||||||
|
/// the complete accumulated stdout and loads each page's PNG
|
||||||
|
/// preview via `TiffPreview` (host-side, never raw TIFF to the UI).
|
||||||
|
public func runPrinttarg(
|
||||||
|
config: PrinttargConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> PrinttargResult {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try PrinttargArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("printtarg")
|
||||||
|
let processId = ProcessID.printtarg(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
|
||||||
|
}
|
||||||
|
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
|
||||||
|
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: PrinttargManifest
|
||||||
|
do {
|
||||||
|
manifest = try PrinttargManifestExtractor.manifest(from: run.stdout)
|
||||||
|
} catch {
|
||||||
|
throw ArgyllRunnerError.malformedManifest(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in
|
||||||
|
let fileURL = cwd.appendingPathComponent(page.filename)
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: nil, previewError: "File not found"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let png = TiffPreview.previewPNG(tiff: fileURL) {
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: png, previewError: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return GalleryPage(
|
||||||
|
index: index, page: page, fileURL: fileURL,
|
||||||
|
previewPNG: nil, previewError: "Could not decode TIFF"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared collection
|
||||||
|
|
||||||
|
/// Cancels any previous child with the same id and waits for it to
|
||||||
|
/// finalize, so `runStreaming` / `runCaptured` never sees a
|
||||||
|
/// `duplicateID` from a leftover process (#50, #52).
|
||||||
|
private func ensureNotRunning(id: String) async {
|
||||||
|
guard await processManager.isRunning(id) else { return }
|
||||||
|
await processManager.kill(id: id)
|
||||||
|
var attempts = 0
|
||||||
|
while await processManager.isRunning(id), attempts < 30 {
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
attempts += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CollectedRun {
|
||||||
|
var exitCode: Int32?
|
||||||
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
|
var lines: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains the event stream until this child's `exit` event.
|
||||||
|
/// stdout is accumulated both per-line (logs) and verbatim (for
|
||||||
|
/// the manifest parse — the pretty JSON needs its newlines).
|
||||||
|
///
|
||||||
|
/// When `flushPartialLines` is `true`, a background `Task` flushes
|
||||||
|
/// unterminated output every 500 ms so tools like `colprof` that
|
||||||
|
/// print dots without newlines still produce log batches.
|
||||||
|
private func collect(
|
||||||
|
id processId: String,
|
||||||
|
events: AsyncStream<ProcessEvent>,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)?,
|
||||||
|
flushPartialLines: Bool = false
|
||||||
|
) async -> CollectedRun {
|
||||||
|
var lines: [String] = []
|
||||||
|
var stdout = ""
|
||||||
|
var stderr = ""
|
||||||
|
var pendingBatch: [String] = []
|
||||||
|
var exitCode: Int32?
|
||||||
|
var lastFlush = Date()
|
||||||
|
|
||||||
|
func flush(_ batch: inout [String]) {
|
||||||
|
guard !batch.isEmpty else { return }
|
||||||
|
let out = batch
|
||||||
|
batch.removeAll(keepingCapacity: true)
|
||||||
|
onLogBatch?(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dotFlushTask: Task<Void, Never>?
|
||||||
|
if flushPartialLines {
|
||||||
|
dotFlushTask = Task { [processManager] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await processManager.flushPartialLine(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
lines.append(line)
|
||||||
|
stdout += line + "\n"
|
||||||
|
pendingBatch.append(line)
|
||||||
|
case .stderr(_, let line):
|
||||||
|
lines.append(line)
|
||||||
|
stderr += line + "\n"
|
||||||
|
pendingBatch.append(line)
|
||||||
|
case .error(_, let message):
|
||||||
|
lines.append("Error: \(message)")
|
||||||
|
pendingBatch.append("Error: \(message)")
|
||||||
|
case .jsonRow:
|
||||||
|
// Only chartread emits these; targen/printtarg never do.
|
||||||
|
break
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingBatch.count >= 20
|
||||||
|
|| Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flush(&pendingBatch)
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
if exitCode != nil {
|
||||||
|
flush(&pendingBatch)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dotFlushTask?.cancel()
|
||||||
|
if let dotFlushTask {
|
||||||
|
_ = await dotFlushTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - instlist (Stage 3 detection)
|
||||||
|
|
||||||
|
/// Runs `instlist` and returns the detected devices.
|
||||||
|
///
|
||||||
|
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
|
||||||
|
/// fallback constrained to known instrument tokens is used.
|
||||||
|
public func detectInstruments() async throws -> [InstrumentDevice] {
|
||||||
|
let binaryURL = binaryResolver.resolve("instlist")
|
||||||
|
let processId = ProcessID.instlist
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: [],
|
||||||
|
workingDirectory: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
var accumulator = JSONAccumulator()
|
||||||
|
var stdout = ""
|
||||||
|
var stderr: [String] = []
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
stdout += line + "\n"
|
||||||
|
_ = accumulator.feed(line: line)
|
||||||
|
case .stderr(_, let line):
|
||||||
|
stderr.append(line)
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if exitCode != nil { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
|
||||||
|
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let code = exitCode, code != 0, stderr.isEmpty == false {
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: try to parse the raw stdout as a text document.
|
||||||
|
if let devices = try? InstrumentParser.parse(stdout) {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - average (Stage 3 multi-pass finish)
|
||||||
|
|
||||||
|
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
|
||||||
|
public func runAverage(
|
||||||
|
config: AverageConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try AverageArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("average")
|
||||||
|
let processId = ProcessID.average(config.basename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
|
||||||
|
guard FileManager.default.fileExists(atPath: canonical.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(canonical.path)
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - colprof (Stage 4)
|
||||||
|
|
||||||
|
/// Runs `colprof` streaming, collecting logs and classifying progress
|
||||||
|
/// until the profile is written.
|
||||||
|
public func runColprof(
|
||||||
|
config: ColprofConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
let args = try ColprofArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("colprof")
|
||||||
|
let processId = ProcessID.colprof(cleanBasename)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(
|
||||||
|
id: processId,
|
||||||
|
events: events,
|
||||||
|
onLogBatch: onLogBatch,
|
||||||
|
flushPartialLines: true
|
||||||
|
)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.colprofFailed(
|
||||||
|
"colprof exited with code \(run.exitCode ?? -1)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
|
||||||
|
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
|
||||||
|
guard let profileURL = ArtefactProbe.resolveProfile(
|
||||||
|
basename: cleanBasename,
|
||||||
|
cwd: cwd
|
||||||
|
) else {
|
||||||
|
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
|
||||||
|
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
|
||||||
|
}
|
||||||
|
return profileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - applycal (post-colprof calibration curve)
|
||||||
|
|
||||||
|
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
|
||||||
|
///
|
||||||
|
/// Runs `applycal` captured and performs an in-place replace via
|
||||||
|
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
|
||||||
|
/// file is removed and the original is left untouched. The UI must
|
||||||
|
/// never request `unapply` (#52).
|
||||||
|
public func runApplycal(
|
||||||
|
config: ApplycalConfig
|
||||||
|
) async throws -> URL {
|
||||||
|
assert(!config.unapply, "runApplycal does not support unapply")
|
||||||
|
|
||||||
|
let inputURL = config.inputProfileURL
|
||||||
|
let cwd = inputURL.deletingLastPathComponent()
|
||||||
|
let binaryURL = binaryResolver.resolve("applycal")
|
||||||
|
let processId = ProcessID.applycal(inputURL.lastPathComponent)
|
||||||
|
|
||||||
|
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
|
||||||
|
let fm = FileManager.default
|
||||||
|
|
||||||
|
// Remove any stale tmp from a previous crash.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
|
||||||
|
let outputConfig = ApplycalConfig(
|
||||||
|
calibrationPath: config.calibrationPath,
|
||||||
|
inputProfileURL: inputURL,
|
||||||
|
outputProfileURL: tmpURL,
|
||||||
|
unapply: false
|
||||||
|
)
|
||||||
|
let outputArgs = try ApplycalArgs.build(config: outputConfig)
|
||||||
|
|
||||||
|
let result = try await processManager.runCaptured(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: outputArgs,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
guard result.exitCode == 0, !Task.isCancelled else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
if Task.isCancelled {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
result.stderr.isEmpty
|
||||||
|
? "applycal exited with code \(result.exitCode)"
|
||||||
|
: result.stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard fm.fileExists(atPath: tmpURL.path) else {
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
"applycal did not create temp profile"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.applycalFailed(
|
||||||
|
"calibrated profile is too small (\(size) bytes)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if fm.fileExists(atPath: inputURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: inputURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iccgamut (post-colprof gamut mesh)
|
||||||
|
|
||||||
|
/// Extracts a `.gam` mesh from the finished profile.
|
||||||
|
public func runIccgamut(
|
||||||
|
config: IccgamutConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> URL {
|
||||||
|
let profileURL = config.profileURL
|
||||||
|
let cwd = profileURL.deletingLastPathComponent()
|
||||||
|
let stem = profileURL.deletingPathExtension().lastPathComponent
|
||||||
|
let args = try IccgamutArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("iccgamut")
|
||||||
|
let processId = ProcessID.iccgamut(stem: stem)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.iccgamutFailed(
|
||||||
|
"iccgamut exited with code \(run.exitCode ?? -1)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let gamURL = cwd.appendingPathComponent("\(stem).gam")
|
||||||
|
guard FileManager.default.fileExists(atPath: gamURL.path) else {
|
||||||
|
throw ArgyllRunnerError.missingArtefact(gamURL.path)
|
||||||
|
}
|
||||||
|
return gamURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - profcheck (Stage 5 verification)
|
||||||
|
|
||||||
|
/// Verifies a profile against the canonical `.ti3`.
|
||||||
|
public func runProfcheck(
|
||||||
|
config: ProfcheckConfig,
|
||||||
|
onLogBatch: (@Sendable ([String]) -> Void)? = nil
|
||||||
|
) async throws -> ProfcheckReport {
|
||||||
|
let cwd = config.ti3URL.deletingLastPathComponent()
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
|
||||||
|
let iccURL = Self.resolveProfileForVerification(config.iccURL)
|
||||||
|
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
|
||||||
|
|
||||||
|
let args = try ProfcheckArgs.build(config: config)
|
||||||
|
let binaryURL = binaryResolver.resolve("profcheck")
|
||||||
|
let processId = ProcessID.profcheck(ti3Path: ti3Path)
|
||||||
|
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
|
||||||
|
|
||||||
|
guard run.exitCode == 0 else {
|
||||||
|
throw ArgyllRunnerError.profcheckFailed(
|
||||||
|
run.stderr.isEmpty
|
||||||
|
? "profcheck exited with code \(run.exitCode ?? -1)"
|
||||||
|
: run.stderr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let report = ProfcheckParser.parse(output)
|
||||||
|
guard report.isValid else {
|
||||||
|
throw ArgyllRunnerError.profcheckUnparseable
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveProfileForVerification(_ url: URL) -> URL {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: url.path) { return url }
|
||||||
|
let alt = url.pathExtension.lowercased() == "icc"
|
||||||
|
? url.deletingPathExtension().appendingPathExtension("icm")
|
||||||
|
: url.deletingPathExtension().appendingPathExtension("icc")
|
||||||
|
return fm.fileExists(atPath: alt.path) ? alt : url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - chartread (Stage 3 interactive)
|
||||||
|
|
||||||
|
/// Runs `chartread` and returns an `AsyncStream` of typed events.
|
||||||
|
///
|
||||||
|
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
|
||||||
|
/// are all handled here. Use `sendChartreadInput` to drive the child and
|
||||||
|
/// `cancelChartread` to terminate it.
|
||||||
|
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
|
||||||
|
let cleanBasename: String
|
||||||
|
let cwd: URL
|
||||||
|
do {
|
||||||
|
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: [String]
|
||||||
|
do {
|
||||||
|
args = try ChartreadArgs.build(config: config)
|
||||||
|
} catch {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let binaryURL = binaryResolver.resolve("chartread")
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
let processManager = self.processManager
|
||||||
|
let isXY = config.isXY
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
await ensureNotRunning(id: processId)
|
||||||
|
let events = processManager.events()
|
||||||
|
|
||||||
|
// Register the XY parking hook before spawning.
|
||||||
|
await processManager.setPreKillHook(id: processId) { [processManager] in
|
||||||
|
if isXY {
|
||||||
|
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await processManager.runStreaming(
|
||||||
|
id: processId,
|
||||||
|
binary: binaryURL,
|
||||||
|
arguments: args,
|
||||||
|
workingDirectory: cwd
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var state: ChartreadState = .idle
|
||||||
|
var pendingLogs: [String] = []
|
||||||
|
var lastFlush = Date()
|
||||||
|
var exitCode: Int32?
|
||||||
|
|
||||||
|
func flushLogs() {
|
||||||
|
guard !pendingLogs.isEmpty else { return }
|
||||||
|
let batch = pendingLogs
|
||||||
|
pendingLogs.removeAll(keepingCapacity: true)
|
||||||
|
continuation.yield(.log(batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
for await event in events {
|
||||||
|
guard event.id == processId else { continue }
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .stdout(_, let line):
|
||||||
|
let previous = state
|
||||||
|
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
|
||||||
|
state = classified.state
|
||||||
|
|
||||||
|
if classified.isRemoveSheetNotice {
|
||||||
|
continuation.yield(.removeSheetNotice)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldPrompt =
|
||||||
|
classified.sheetNumber != nil
|
||||||
|
|| classified.alignmentPatch != nil
|
||||||
|
|| classified.requestedWarningKey != nil
|
||||||
|
|| classified.state != previous
|
||||||
|
|| classified.isTableContinuation
|
||||||
|
|
||||||
|
if shouldPrompt {
|
||||||
|
continuation.yield(.prompt(classified))
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .stderr(_, let line):
|
||||||
|
pendingLogs.append(line)
|
||||||
|
|
||||||
|
case .jsonRow(_, let payload):
|
||||||
|
do {
|
||||||
|
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
|
||||||
|
state = row.isFinalRow ? .allStripsRead : state
|
||||||
|
continuation.yield(.row(row))
|
||||||
|
} catch {
|
||||||
|
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .error(_, let message):
|
||||||
|
pendingLogs.append("Error: \(message)")
|
||||||
|
|
||||||
|
case .exit(_, let code):
|
||||||
|
exitCode = code
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode == nil,
|
||||||
|
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
|
||||||
|
flushLogs()
|
||||||
|
lastFlush = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != nil {
|
||||||
|
flushLogs()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
if let code = exitCode, code == 0 {
|
||||||
|
if FileManager.default.fileExists(atPath: canonical.path) {
|
||||||
|
continuation.yield(.completed(canonical))
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an exact input sequence to the running `chartread` child.
|
||||||
|
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
try await processManager.sendStdin(id: processId, bytes: input.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminate a running `chartread` child.
|
||||||
|
///
|
||||||
|
/// The actual XY parking is handled by the pre-kill hook registered in
|
||||||
|
/// `runChartread`.
|
||||||
|
public func cancelChartread(basename: String, isXY: Bool = false) {
|
||||||
|
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
|
||||||
|
guard let cleanBasename else { return }
|
||||||
|
let processId = ProcessID.chartread(cleanBasename)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await processManager.kill(id: processId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events emitted by a running `chartread` session.
|
||||||
|
public enum ChartreadEvent: Sendable {
|
||||||
|
/// Classified prompt / state update.
|
||||||
|
case prompt(ChartreadClassifyResult)
|
||||||
|
/// A decoded `ROW_COLORS_JSON` row.
|
||||||
|
case row(ChartreadRow)
|
||||||
|
/// A batched log chunk (stdout + stderr lines).
|
||||||
|
case log([String])
|
||||||
|
/// Informational "remove last sheet" notice.
|
||||||
|
case removeSheetNotice
|
||||||
|
/// Process exited with the given code.
|
||||||
|
case exit(Int32)
|
||||||
|
/// Successful completion with the canonical `.ti3` URL.
|
||||||
|
case completed(URL)
|
||||||
|
/// Failure (non-zero exit, missing artefact, spawn/parse error).
|
||||||
|
case failed(ArgyllRunnerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact bytes sent to `chartread` stdin.
|
||||||
|
public enum ChartreadInput: Sendable {
|
||||||
|
case trigger // " \n"
|
||||||
|
case accept // "\n"
|
||||||
|
case done // "d\n"
|
||||||
|
case quit // "q\n"
|
||||||
|
case customKey(String)
|
||||||
|
|
||||||
|
public var bytes: Data {
|
||||||
|
switch self {
|
||||||
|
case .trigger:
|
||||||
|
return Data(" \n".utf8)
|
||||||
|
case .accept:
|
||||||
|
return Data("\n".utf8)
|
||||||
|
case .done:
|
||||||
|
return Data("d\n".utf8)
|
||||||
|
case .quit:
|
||||||
|
return Data("q\n".utf8)
|
||||||
|
case .customKey(let key):
|
||||||
|
return Data("\(key)\n".utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,372 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors that can occur while parsing CGATS-like data.
|
||||||
|
public enum CGATSParseError: Error, Equatable {
|
||||||
|
case emptyFile
|
||||||
|
case missingBeginDataFormat
|
||||||
|
case missingEndDataFormat
|
||||||
|
case missingBeginData
|
||||||
|
case missingEndData
|
||||||
|
case missingNumberOfFields
|
||||||
|
case missingNumberOfSets
|
||||||
|
case unknownFieldName(String)
|
||||||
|
case malformedRow(line: Int, reason: String)
|
||||||
|
case nonNumericValue(field: String, value: String, line: Int)
|
||||||
|
case outOfBoundsValue(field: String, value: Double, line: Int)
|
||||||
|
case implausibleValue(field: String, value: Double, line: Int)
|
||||||
|
case incorrectArity(line: Int, expected: Int, got: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a CGATS dataset, keyed by canonical field name.
|
||||||
|
public struct CGATSSample: Sendable, Equatable {
|
||||||
|
public var id: String
|
||||||
|
public var loc: String?
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed CGATS / CTI3 / CSV dataset.
|
||||||
|
public struct CGATSDataset: Sendable, Equatable {
|
||||||
|
public var format: CGATSFormat
|
||||||
|
public var keywords: [String: String]
|
||||||
|
public var fieldNames: [String]
|
||||||
|
public var samples: [CGATSSample]
|
||||||
|
public var colorRep: String?
|
||||||
|
public var deviceClass: String?
|
||||||
|
public var targetInstrument: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
format: CGATSFormat,
|
||||||
|
keywords: [String: String] = [:],
|
||||||
|
fieldNames: [String] = [],
|
||||||
|
samples: [CGATSSample] = [],
|
||||||
|
colorRep: String? = nil,
|
||||||
|
deviceClass: String? = nil,
|
||||||
|
targetInstrument: String? = nil
|
||||||
|
) {
|
||||||
|
self.format = format
|
||||||
|
self.keywords = keywords
|
||||||
|
self.fieldNames = fieldNames
|
||||||
|
self.samples = samples
|
||||||
|
self.colorRep = colorRep
|
||||||
|
self.deviceClass = deviceClass
|
||||||
|
self.targetInstrument = targetInstrument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CGATSFormat: String, Sendable, Equatable {
|
||||||
|
case cti3 = "CTI3"
|
||||||
|
case cgats17 = "CGATS.17"
|
||||||
|
case csv = "CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
|
||||||
|
public enum CGATSParser {
|
||||||
|
|
||||||
|
/// Parse the contents of a CGATS-like file.
|
||||||
|
public static func parse(
|
||||||
|
_ contents: String,
|
||||||
|
sourceURL: URL? = nil
|
||||||
|
) throws(CGATSParseError) -> CGATSDataset {
|
||||||
|
guard !contents.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
let ext = sourceURL?.pathExtension.lowercased() ?? ""
|
||||||
|
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.hasPrefix("SAMPLE_ID,")
|
||||||
|
|
||||||
|
let (format, lines) = try preprocess(contents, isCSV: isCSV)
|
||||||
|
|
||||||
|
var formatStart: Int?
|
||||||
|
var formatEnd: Int?
|
||||||
|
var dataStart: Int?
|
||||||
|
var dataEnd: Int?
|
||||||
|
var keywords = [String: String]()
|
||||||
|
|
||||||
|
for (index, line) in lines.enumerated() {
|
||||||
|
switch Self.normalizedKeyword(line) {
|
||||||
|
case "BEGIN_DATA_FORMAT": formatStart = index
|
||||||
|
case "END_DATA_FORMAT": formatEnd = index
|
||||||
|
case "BEGIN_DATA": dataStart = index
|
||||||
|
case "END_DATA": dataEnd = index
|
||||||
|
default:
|
||||||
|
if let (key, value) = parseKeyword(line) {
|
||||||
|
keywords[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
|
||||||
|
throw .missingBeginDataFormat
|
||||||
|
}
|
||||||
|
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
|
||||||
|
throw .missingBeginData
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawFieldNames = splitFields(lines[formatStart + 1])
|
||||||
|
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
|
||||||
|
|
||||||
|
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
|
||||||
|
numberOfFields != fieldNames.count {
|
||||||
|
// Warn only; the data format line is the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_FIELDS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
|
||||||
|
numberOfSets != dataEnd - dataStart - 1 {
|
||||||
|
// Warn only; the actual rows are the source of truth.
|
||||||
|
} else if keywords["NUMBER_OF_SETS"] == nil {
|
||||||
|
// Optional header; do not fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawSample {
|
||||||
|
var id: String
|
||||||
|
var loc: String?
|
||||||
|
var numbers: [String: Double] = [:]
|
||||||
|
var strings: [String: String] = [:]
|
||||||
|
var lineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawSamples = [RawSample]()
|
||||||
|
var groupMax: [String: Double] = [:]
|
||||||
|
|
||||||
|
for offset in 1...(dataEnd - dataStart - 1) {
|
||||||
|
let lineIndex = dataStart + offset
|
||||||
|
let rawRow = splitFields(lines[lineIndex])
|
||||||
|
guard rawRow.count == fieldNames.count else {
|
||||||
|
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
|
||||||
|
for (i, name) in fieldNames.enumerated() {
|
||||||
|
let raw = stripInlineComment(rawRow[i])
|
||||||
|
if isNumericField(name) {
|
||||||
|
let cleaned = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let number = parseNumber(cleaned) {
|
||||||
|
sample.numbers[name] = number
|
||||||
|
if let group = deviceGroup(name) {
|
||||||
|
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
|
||||||
|
}
|
||||||
|
} else if !cleaned.isEmpty {
|
||||||
|
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sample.strings[name] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
|
||||||
|
sample.loc = sample.strings["SAMPLE_LOC"]
|
||||||
|
rawSamples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples = [CGATSSample]()
|
||||||
|
for raw in rawSamples {
|
||||||
|
var values = raw.strings
|
||||||
|
for (name, number) in raw.numbers {
|
||||||
|
var scaled = number
|
||||||
|
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
|
||||||
|
scaled = number / 2.55
|
||||||
|
}
|
||||||
|
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
|
||||||
|
// Keep lookups by canonical keys, but also preserve original aliases.
|
||||||
|
let rawRow = splitFields(lines[raw.lineIndex])
|
||||||
|
for (i, rawName) in rawFieldNames.enumerated() {
|
||||||
|
let canonical = canonicalFieldName(rawName)
|
||||||
|
if canonical != rawName {
|
||||||
|
sample.values[rawName] = rawRow[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
samples.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
|
||||||
|
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
|
||||||
|
|
||||||
|
return CGATSDataset(
|
||||||
|
format: format,
|
||||||
|
keywords: keywords,
|
||||||
|
fieldNames: fieldNames,
|
||||||
|
samples: samples,
|
||||||
|
colorRep: colorRep,
|
||||||
|
deviceClass: deviceClass,
|
||||||
|
targetInstrument: keywords["TARGET_INSTRUMENT"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a URL (throws as `Error` for public callers).
|
||||||
|
public static func parse(url: URL) throws -> CGATSDataset {
|
||||||
|
let contents = try String(contentsOf: url)
|
||||||
|
return try parse(contents, sourceURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func preprocess(
|
||||||
|
_ contents: String,
|
||||||
|
isCSV: Bool
|
||||||
|
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
|
||||||
|
let allLines = contents.components(separatedBy: .newlines)
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
var format: CGATSFormat?
|
||||||
|
for var line in allLines {
|
||||||
|
line = stripComment(line)
|
||||||
|
line = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !line.isEmpty else { continue }
|
||||||
|
|
||||||
|
if format == nil {
|
||||||
|
if line.hasPrefix("CTI3") { format = .cti3 }
|
||||||
|
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
|
||||||
|
else if isCSV { format = .csv }
|
||||||
|
}
|
||||||
|
|
||||||
|
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
|
||||||
|
line == "BEGIN_DATA" || line == "END_DATA" ||
|
||||||
|
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
|
||||||
|
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
|
||||||
|
// These are exact keywords; keep them intact.
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lines.isEmpty else { throw .emptyFile }
|
||||||
|
|
||||||
|
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
|
||||||
|
// structure so the boundary-based parser below can handle it.
|
||||||
|
if let format, format == .csv,
|
||||||
|
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
|
||||||
|
let header = lines[0]
|
||||||
|
let data = lines.dropFirst()
|
||||||
|
lines = [
|
||||||
|
"CTI3",
|
||||||
|
"BEGIN_DATA_FORMAT",
|
||||||
|
header,
|
||||||
|
"END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA"
|
||||||
|
] + Array(data) + [
|
||||||
|
"END_DATA"
|
||||||
|
]
|
||||||
|
return (.csv, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (format ?? .cti3, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripComment(_ line: String) -> String {
|
||||||
|
if let range = line.range(of: "#") {
|
||||||
|
return String(line[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stripInlineComment(_ token: String) -> String {
|
||||||
|
if let range = token.range(of: "#") {
|
||||||
|
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitFields(_ line: String) -> [String] {
|
||||||
|
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
|
||||||
|
if line.contains(",") {
|
||||||
|
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
|
||||||
|
// KEYWORD value or KEYWORD "value"
|
||||||
|
let tokens = splitFields(line)
|
||||||
|
guard let key = tokens.first else { return nil }
|
||||||
|
|
||||||
|
// Data-boundary keywords are not value keywords.
|
||||||
|
let boundaryKeys = Set([
|
||||||
|
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
|
||||||
|
"BEGIN_DATA", "END_DATA"
|
||||||
|
])
|
||||||
|
guard !boundaryKeys.contains(key) else { return nil }
|
||||||
|
|
||||||
|
let rawValue = tokens.dropFirst().joined(separator: " ")
|
||||||
|
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
return (key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedKeyword(_ line: String) -> String {
|
||||||
|
line.uppercased().trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field name normalization
|
||||||
|
|
||||||
|
private static func canonicalFieldName(_ raw: String) -> String {
|
||||||
|
let upper = raw.uppercased()
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
.replacingOccurrences(of: "-", with: "_")
|
||||||
|
switch upper {
|
||||||
|
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
|
||||||
|
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
|
||||||
|
case "SAMPLE_NAME": return "SAMPLE_ID"
|
||||||
|
case "LAB_L", "L*", "L_AB": return "LAB_L"
|
||||||
|
case "LAB_A", "A*", "A_AB": return "LAB_A"
|
||||||
|
case "LAB_B", "B*", "B_AB": return "LAB_B"
|
||||||
|
case "XYZ_X", "X": return "XYZ_X"
|
||||||
|
case "XYZ_Y", "Y": return "XYZ_Y"
|
||||||
|
case "XYZ_Z", "Z": return "XYZ_Z"
|
||||||
|
default: return upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isNumericField(_ name: String) -> Bool {
|
||||||
|
let numericNames: Set = [
|
||||||
|
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
|
||||||
|
]
|
||||||
|
return !numericNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseNumber(_ raw: String) -> Double? {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.number(from: raw)?.doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
|
||||||
|
var number = value
|
||||||
|
|
||||||
|
// Plausibility checks for Lab and XYZ.
|
||||||
|
if field == "LAB_L" { number = max(0, min(160, number)) }
|
||||||
|
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
|
||||||
|
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
|
||||||
|
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deviceGroup(_ name: String) -> String? {
|
||||||
|
if name.hasPrefix("RGB_") { return "RGB" }
|
||||||
|
if name.hasPrefix("CMYK_") { return "CMYK" }
|
||||||
|
if name.hasPrefix("DEVICE_") { return "DEVICE" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferColorRep(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferDeviceClass(fieldNames: [String]) -> String? {
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
|
||||||
|
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
|
||||||
|
return "OUTPUT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable summary of an imported CGATS dataset.
|
||||||
|
public struct CGATSSummary: Sendable, Equatable {
|
||||||
|
public let patchCount: Int
|
||||||
|
public let colorSpace: String?
|
||||||
|
public let deviceClass: String?
|
||||||
|
public let hasSpectral: Bool
|
||||||
|
public let previewRows: [String]
|
||||||
|
|
||||||
|
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
|
||||||
|
self.patchCount = dataset.samples.count
|
||||||
|
self.colorSpace = dataset.colorRep
|
||||||
|
self.deviceClass = dataset.deviceClass
|
||||||
|
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
|
||||||
|
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
|
||||||
|
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from writing a canonical `.ti3` dataset.
|
||||||
|
public enum CGATSWriterError: Error, Equatable {
|
||||||
|
case noSamples
|
||||||
|
case missingRequiredField(String)
|
||||||
|
case invalidValue(field: String, value: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
|
||||||
|
public enum CGATSWriter {
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset) throws -> String {
|
||||||
|
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
|
||||||
|
throw CGATSWriterError.noSamples
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = [String]()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.append(dataset.format.rawValue)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
|
||||||
|
if let colorRep = dataset.colorRep {
|
||||||
|
lines.append("COLOR_REP \"\(colorRep)\"")
|
||||||
|
}
|
||||||
|
if let deviceClass = dataset.deviceClass {
|
||||||
|
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
|
||||||
|
}
|
||||||
|
if let instrument = dataset.targetInstrument {
|
||||||
|
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
|
||||||
|
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA_FORMAT")
|
||||||
|
lines.append(dataset.fieldNames.joined(separator: "\t"))
|
||||||
|
lines.append("END_DATA_FORMAT")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("BEGIN_DATA")
|
||||||
|
for sample in dataset.samples {
|
||||||
|
let row = try dataset.fieldNames.map { field in
|
||||||
|
guard let raw = sample.values[field], !raw.isEmpty else {
|
||||||
|
throw CGATSWriterError.missingRequiredField(field)
|
||||||
|
}
|
||||||
|
// Normalize numeric fields to a compact decimal.
|
||||||
|
if isNumeric(field) {
|
||||||
|
return normalizedNumber(raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
lines.append(row.joined(separator: "\t"))
|
||||||
|
}
|
||||||
|
lines.append("END_DATA")
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
|
||||||
|
let text = try write(dataset)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func isNumeric(_ field: String) -> Bool {
|
||||||
|
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
|
||||||
|
return !nonNumeric.contains(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedNumber(_ raw: String) -> String {
|
||||||
|
guard let number = Double(raw) else { return raw }
|
||||||
|
if number == floor(number) {
|
||||||
|
return String(format: "%.0f", number)
|
||||||
|
}
|
||||||
|
return String(format: "%.4f", number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Small host-side file helpers (issue #6).
|
||||||
|
public enum ArtefactFiles {
|
||||||
|
|
||||||
|
/// `get_default_working_dir` — `resolveSafeCwd(nil)`.
|
||||||
|
public static func defaultWorkingDirectory() -> URL {
|
||||||
|
PathSecurity.resolveSafeCwd(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `read_file_base64` — for **text artefacts** the UI needs verbatim
|
||||||
|
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
|
||||||
|
/// go through `TiffPreview` instead.
|
||||||
|
public static func readBase64(_ url: URL) throws -> String {
|
||||||
|
try Data(contentsOf: url).base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `get_app_info` — version, build, and build date for the About dialog.
|
||||||
|
public static func appInfo(
|
||||||
|
bundle: Bundle = .main
|
||||||
|
) -> (version: String, build: String, buildDate: String) {
|
||||||
|
let info = bundle.infoDictionary ?? [:]
|
||||||
|
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
|
let build = info["CFBundleVersion"] as? String ?? "0"
|
||||||
|
|
||||||
|
let url = bundle.executableURL ?? bundle.bundleURL
|
||||||
|
let buildDate: String
|
||||||
|
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
|
||||||
|
let date = values.contentModificationDate {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
buildDate = formatter.string(from: date)
|
||||||
|
} else {
|
||||||
|
buildDate = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (version, build, buildDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
|
||||||
|
public struct StageArtefacts: Sendable, Equatable {
|
||||||
|
/// `<basename>.ti1` exists (Stage 1 done → unlocks Stage 2).
|
||||||
|
public var stage1Complete = false
|
||||||
|
/// `<basename>.ti2` exists (Stage 2 done → with ti1, unlocks Stage 3).
|
||||||
|
public var stage2Complete = false
|
||||||
|
/// `<basename>.ti3` exists (Stage 3 done → unlocks Stage 4).
|
||||||
|
public var stage3Complete = false
|
||||||
|
/// `.icc`/`.icm` exists (Stage 4 done → with ti3, unlocks Stage 5).
|
||||||
|
public var stage4Complete = false
|
||||||
|
/// Absolute path of the profile file when present.
|
||||||
|
public var profilePath: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stage1Complete: Bool = false,
|
||||||
|
stage2Complete: Bool = false,
|
||||||
|
stage3Complete: Bool = false,
|
||||||
|
stage4Complete: Bool = false,
|
||||||
|
profilePath: URL? = nil
|
||||||
|
) {
|
||||||
|
self.stage1Complete = stage1Complete
|
||||||
|
self.stage2Complete = stage2Complete
|
||||||
|
self.stage3Complete = stage3Complete
|
||||||
|
self.stage4Complete = stage4Complete
|
||||||
|
self.profilePath = profilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
|
||||||
|
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
|
||||||
|
public enum ArtefactProbe {
|
||||||
|
|
||||||
|
/// `verify_stage_artefacts` — the gating truth source.
|
||||||
|
public static func verify(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> StageArtefacts {
|
||||||
|
var out = StageArtefacts()
|
||||||
|
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
|
||||||
|
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
|
||||||
|
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
|
||||||
|
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
|
||||||
|
out.stage4Complete = true
|
||||||
|
out.profilePath = profile
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<cwd>/<basename>.<ext>` — the canonical artefact URL.
|
||||||
|
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
|
||||||
|
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile extension resolution (#69): existing `.icm` wins over
|
||||||
|
/// `.icc`; when neither exists the macOS default is `.icc`.
|
||||||
|
/// (`profcheck`/`iccgamut` swap extension when the requested path is
|
||||||
|
/// missing.)
|
||||||
|
public static func resolveProfile(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL? {
|
||||||
|
let icm = artefact(basename, "icm", cwd)
|
||||||
|
if exists(icm, fm: fileManager) { return icm }
|
||||||
|
let icc = artefact(basename, "icc", cwd)
|
||||||
|
if exists(icc, fm: fileManager) { return icc }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default extension for a *new* profile on macOS (#69).
|
||||||
|
public static let defaultProfileExtension = "icc"
|
||||||
|
|
||||||
|
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
|
||||||
|
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
|
||||||
|
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` … and
|
||||||
|
/// `<basename>_NN.tif` (manifest naming).
|
||||||
|
public static func existingArtefacts(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefixes = [basename + ".", "CAL_" + basename + "."]
|
||||||
|
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
|
||||||
|
let passPrefix = basename + "_pass"
|
||||||
|
let tifStemPrefix = basename + "_"
|
||||||
|
let calPrefix = "CAL_" + basename
|
||||||
|
|
||||||
|
return entries.filter { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
guard suffixes.contains(ext) else { return false }
|
||||||
|
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
|
||||||
|
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
|
||||||
|
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
|
||||||
|
if name.hasPrefix(calPrefix) { return true }
|
||||||
|
return false
|
||||||
|
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func exists(_ url: URL, fm: FileManager) -> Bool {
|
||||||
|
fm.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Atomic `.tmp`-then-rename file writes — the convention used by
|
||||||
|
/// settings.json, verification_history.json and wizard_state.json
|
||||||
|
/// (docs/02 §Persistence, #213).
|
||||||
|
public enum AtomicFileWriter {
|
||||||
|
|
||||||
|
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
|
||||||
|
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
|
||||||
|
public static func write(_ data: Data, to url: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let dir = url.deletingLastPathComponent()
|
||||||
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let tmp = url.appendingPathExtension("tmp")
|
||||||
|
do {
|
||||||
|
try data.write(to: tmp, options: [])
|
||||||
|
// replaceItemAt handles same-volume atomic swap and removes
|
||||||
|
// the destination cleanly; fall back to remove+move.
|
||||||
|
if fm.fileExists(atPath: url.path) {
|
||||||
|
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmp, to: url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmp)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ text: String, to url: URL) throws {
|
||||||
|
try write(Data(text.utf8), to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Basename sanitisation and safe working-directory resolution
|
||||||
|
/// (docs/02 §Working directory, docs/06 §Empty cwd).
|
||||||
|
public enum PathSecurity {
|
||||||
|
|
||||||
|
public enum Error: Swift.Error, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basenames must not contain `/`, `\`, or `..` and must be
|
||||||
|
/// non-empty. Never invent a default basename (#60).
|
||||||
|
public static func isValidBasename(_ name: String) -> Bool {
|
||||||
|
guard !name.isEmpty else { return false }
|
||||||
|
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func sanitizeBasename(_ name: String) throws -> String {
|
||||||
|
guard isValidBasename(name) else {
|
||||||
|
throw Error.invalidBasename(name)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory →
|
||||||
|
/// Documents → Home → app-data. Never returns an empty/nil cwd.
|
||||||
|
public static func resolveSafeCwd(
|
||||||
|
_ explicit: URL?,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> URL {
|
||||||
|
if let explicit,
|
||||||
|
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
let candidates: [URL?] = [
|
||||||
|
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||||
|
fileManager.homeDirectoryForCurrentUser,
|
||||||
|
AppPaths.appDataDir,
|
||||||
|
]
|
||||||
|
for candidate in candidates {
|
||||||
|
guard let url = candidate else { continue }
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: app-data, created unconditionally.
|
||||||
|
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
|
||||||
|
return AppPaths.appDataDir
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
|
||||||
|
/// `parse_ti2_header` reads only CGATS keyword lines — the data grid
|
||||||
|
/// itself belongs to issue #30.
|
||||||
|
public struct Ti2Header: Sendable, Equatable {
|
||||||
|
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
|
||||||
|
public var instrument: String?
|
||||||
|
/// `NUMBER_OF_SETS` — the patch count. Note: `NUMBER_OF_FIELDS` is
|
||||||
|
/// the CGATS column count, *not* the patch count.
|
||||||
|
public var patchCount: Int?
|
||||||
|
/// `NUMBER_OF_PAGES`.
|
||||||
|
public var pageCount: Int?
|
||||||
|
/// A sibling `<stem>.ti1` exists next to the parsed file.
|
||||||
|
public var hasSiblingTi1 = false
|
||||||
|
|
||||||
|
public static func parse(
|
||||||
|
_ url: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Ti2Header {
|
||||||
|
var header = Ti2Header()
|
||||||
|
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
|
||||||
|
guard let space = line.firstIndex(of: " ") else { continue }
|
||||||
|
let key = String(line[..<space])
|
||||||
|
let value = String(line[line.index(after: space)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
switch key {
|
||||||
|
case "TARGET_INSTRUMENT":
|
||||||
|
header.instrument = value
|
||||||
|
case "NUMBER_OF_SETS":
|
||||||
|
header.patchCount = Int(value)
|
||||||
|
case "NUMBER_OF_PAGES":
|
||||||
|
header.pageCount = Int(value)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stem = url.deletingPathExtension()
|
||||||
|
header.hasSiblingTi1 = fileManager.fileExists(
|
||||||
|
atPath: stem.appendingPathExtension("ti1").path
|
||||||
|
)
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// TIFF → PNG preview for the Stage 2 gallery (#58): decode on the host
|
||||||
|
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
|
||||||
|
/// bytes to the UI.
|
||||||
|
public enum TiffPreview {
|
||||||
|
|
||||||
|
public static let maxEdge: Int = 1200
|
||||||
|
|
||||||
|
/// Returns PNG data for the first page of a TIFF, or `nil` when the
|
||||||
|
/// file cannot be decoded.
|
||||||
|
public static func previewPNG(
|
||||||
|
tiff url: URL,
|
||||||
|
maxEdge: Int = Self.maxEdge
|
||||||
|
) -> Data? {
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
]
|
||||||
|
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
source, 0, options as CFDictionary
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
let out = NSMutableData()
|
||||||
|
guard let dest = CGImageDestinationCreateWithData(
|
||||||
|
out, UTType.png.identifier as CFString, 1, nil
|
||||||
|
) else { return nil }
|
||||||
|
CGImageDestinationAddImage(dest, image, nil)
|
||||||
|
guard CGImageDestinationFinalize(dest) else { return nil }
|
||||||
|
return out as Data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,302 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Discrete states for the `chartread` interaction.
|
||||||
|
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
|
||||||
|
case idle
|
||||||
|
case calibrating
|
||||||
|
case awaitingStrip
|
||||||
|
case reading
|
||||||
|
case allStripsRead
|
||||||
|
case warning
|
||||||
|
case promptContinue
|
||||||
|
case tablePlaceSheet
|
||||||
|
case tableAlign
|
||||||
|
case error
|
||||||
|
case finished
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra metadata produced by classifying a single `chartread` stdout line.
|
||||||
|
public struct ChartreadClassifyResult: Sendable, Equatable {
|
||||||
|
public let state: ChartreadState
|
||||||
|
/// Whether this line is an informational "remove last sheet" notice.
|
||||||
|
public let isRemoveSheetNotice: Bool
|
||||||
|
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
|
||||||
|
public let sheetNumber: Int?
|
||||||
|
public let sheetTotal: Int?
|
||||||
|
/// Fiducial patch name from XY "locate patch X with the sight".
|
||||||
|
public let alignmentPatch: String?
|
||||||
|
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
|
||||||
|
public let requestedWarningKey: String?
|
||||||
|
/// Whether the line is a continuation of a multi-line XY prompt.
|
||||||
|
public let isTableContinuation: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: ChartreadState,
|
||||||
|
isRemoveSheetNotice: Bool = false,
|
||||||
|
sheetNumber: Int? = nil,
|
||||||
|
sheetTotal: Int? = nil,
|
||||||
|
alignmentPatch: String? = nil,
|
||||||
|
requestedWarningKey: String? = nil,
|
||||||
|
isTableContinuation: Bool = false
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.isRemoveSheetNotice = isRemoveSheetNotice
|
||||||
|
self.sheetNumber = sheetNumber
|
||||||
|
self.sheetTotal = sheetTotal
|
||||||
|
self.alignmentPatch = alignmentPatch
|
||||||
|
self.requestedWarningKey = requestedWarningKey
|
||||||
|
self.isTableContinuation = isTableContinuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure line classifier for `chartread` stdout.
|
||||||
|
///
|
||||||
|
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
|
||||||
|
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
|
||||||
|
public enum ChartreadClassifier {
|
||||||
|
|
||||||
|
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
|
||||||
|
|
||||||
|
public static func classify(
|
||||||
|
line: String,
|
||||||
|
previousState: ChartreadState
|
||||||
|
) -> ChartreadClassifyResult {
|
||||||
|
let text = line.lowercased()
|
||||||
|
|
||||||
|
for matcher in matchers(previousState) {
|
||||||
|
if let result = matcher(text, previousState) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: previousState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
|
||||||
|
[
|
||||||
|
removeSheetNotice,
|
||||||
|
sheetReadOk,
|
||||||
|
locatePatch,
|
||||||
|
placeSheet,
|
||||||
|
continuation(previous),
|
||||||
|
done,
|
||||||
|
warning,
|
||||||
|
calibration,
|
||||||
|
awaitingStrip,
|
||||||
|
reading,
|
||||||
|
error
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. "Please remove last sheet from table" — info only.
|
||||||
|
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. "Sheet N of M read OK".
|
||||||
|
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: previous,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. "locate patch X with the sight".
|
||||||
|
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
|
||||||
|
!match.0.isEmpty else { return nil }
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tableAlign,
|
||||||
|
alignmentPatch: match.0.uppercased()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. "place sheet N of M" or "remove previous sheet".
|
||||||
|
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
|
||||||
|
return ChartreadClassifyResult(
|
||||||
|
state: .tablePlaceSheet,
|
||||||
|
sheetNumber: match.1,
|
||||||
|
sheetTotal: match.2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if text.contains("remove previous sheet") || text.contains("place sheet") {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. "hit return to continue" — sticky if already in a table state.
|
||||||
|
private static func continuation(_ previous: ChartreadState) -> Matcher {
|
||||||
|
return { text, _ in
|
||||||
|
guard text.contains("hit return to continue")
|
||||||
|
|| text.contains("hit any key to continue")
|
||||||
|
|| text.contains("hit space to continue")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if case .tablePlaceSheet = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
if case .tableAlign = previous {
|
||||||
|
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Done / all read.
|
||||||
|
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let phrases = [
|
||||||
|
"'d' if/when done", "d to finish/save", "all strips/patches read",
|
||||||
|
"all strips read", "all patches read", "done reading",
|
||||||
|
"'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
|
||||||
|
]
|
||||||
|
if phrases.contains(where: { text.contains($0) }) {
|
||||||
|
return ChartreadClassifyResult(state: .allStripsRead)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Warnings / prompts needing a key.
|
||||||
|
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text
|
||||||
|
let warningSignals = [
|
||||||
|
"(warning)", "use it anyway", "seem to have read strip",
|
||||||
|
"unexpected response", "try again", "do you want to",
|
||||||
|
"abort ? - are you sure", "are you sure"
|
||||||
|
]
|
||||||
|
|
||||||
|
let isWarningPrompt =
|
||||||
|
warningSignals.contains(where: { lower.contains($0) })
|
||||||
|
|| lower.contains("(y/n)")
|
||||||
|
|| lower.contains("'y' or 'n'")
|
||||||
|
|| lower.contains("?")
|
||||||
|
|
||||||
|
guard isWarningPrompt else { return nil }
|
||||||
|
|
||||||
|
var key: String?
|
||||||
|
if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
|
||||||
|
// Default to asking the user; no automatic key.
|
||||||
|
key = nil
|
||||||
|
} else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
|
||||||
|
key = "y"
|
||||||
|
} else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
|
||||||
|
key = "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Calibration / place reference / white / standard tile.
|
||||||
|
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
|
||||||
|
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
|
||||||
|
let hasLocate = lowercased.contains("locate patch")
|
||||||
|
|
||||||
|
guard placeTokens.contains(where: { lowercased.contains($0) }),
|
||||||
|
!hasPlaceSheet,
|
||||||
|
!hasLocate
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
if lowercased.contains("calibrat")
|
||||||
|
|| lowercased.contains("white reference")
|
||||||
|
|| lowercased.contains("white tile")
|
||||||
|
|| lowercased.contains("standard tile")
|
||||||
|
|| lowercased.contains("reference")
|
||||||
|
|| lowercased.contains("tile")
|
||||||
|
|| lowercased.contains("hit any key to continue")
|
||||||
|
|| lowercased.contains("hit space to continue") {
|
||||||
|
return ChartreadClassifyResult(state: .calibrating)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Awaiting strip.
|
||||||
|
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
|
||||||
|
// These are explicit, multi-word prompts; we deliberately do NOT
|
||||||
|
// match bare "read strip" so that error lines like
|
||||||
|
// "failed to read strip" or "error reading strip" fall through to
|
||||||
|
// the error matcher.
|
||||||
|
let phrases = [
|
||||||
|
"ready to read",
|
||||||
|
"hit any key to read",
|
||||||
|
"hit a key to read",
|
||||||
|
"hit space to read",
|
||||||
|
"hit [space] to read",
|
||||||
|
"press any key to read",
|
||||||
|
"press space to read",
|
||||||
|
"trigger instrument",
|
||||||
|
"start reading",
|
||||||
|
"read next strip"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Also permit "hit X to read strip Y" or "ready to read strip Z".
|
||||||
|
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .awaitingStrip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Reading.
|
||||||
|
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lowercased = text.lowercased()
|
||||||
|
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
|
||||||
|
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
|
||||||
|
return ChartreadClassifyResult(state: .reading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Error.
|
||||||
|
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
|
||||||
|
let lower = text.lowercased()
|
||||||
|
|
||||||
|
// Avoid false positives from confirmation prompts and "no error" status.
|
||||||
|
guard !lower.contains("no error") else { return nil }
|
||||||
|
guard !lower.contains("(y/n)")
|
||||||
|
&& !lower.contains("'y' or 'n'")
|
||||||
|
&& !lower.contains("?")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
|
||||||
|
for phrase in phraseMatches {
|
||||||
|
if lower.contains(phrase) {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-word "error" only — bare "failed" alone is not enough.
|
||||||
|
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
|
||||||
|
return ChartreadClassifyResult(state: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func firstMatch(pattern: String) -> (String, Int, Int)? {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
|
||||||
|
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
|
||||||
|
let r = match.range(at: i)
|
||||||
|
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
|
||||||
|
return String(self[range])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let first = groups.first else { return nil }
|
||||||
|
let ints = groups.compactMap { Int($0) }
|
||||||
|
let a = ints.count > 0 ? ints[0] : 0
|
||||||
|
let b = ints.count > 1 ? ints[1] : 0
|
||||||
|
return (first, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `ChartreadArgs` validation.
|
||||||
|
public enum ChartreadArgError: LocalizedError, Equatable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case invalidPort(Int)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid chart basename: \(name)"
|
||||||
|
case .invalidPort(let port):
|
||||||
|
return "Invalid chartread port: \(port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a `chartread` invocation.
|
||||||
|
public struct ChartreadConfig: Codable, Equatable, Sendable {
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
/// Communication port to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (Auto or port 1).
|
||||||
|
public var selectedPort: Int?
|
||||||
|
/// Enable i1Pro 2 visual LEDs (`-Y l`).
|
||||||
|
public var enableLEDs: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil,
|
||||||
|
selectedPort: Int? = nil,
|
||||||
|
enableLEDs: Bool = false,
|
||||||
|
isXY: Bool = false
|
||||||
|
) {
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
self.selectedPort = selectedPort
|
||||||
|
self.enableLEDs = enableLEDs
|
||||||
|
self.isXY = isXY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current config implies an XY-table workflow.
|
||||||
|
/// This is normally supplied by the view model from the selected instrument.
|
||||||
|
public var isXY: Bool = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single patch read by `chartread`.
|
||||||
|
public struct ChartreadPatch: Codable, Sendable, Equatable {
|
||||||
|
public let id: String
|
||||||
|
public let loc: String
|
||||||
|
public let isPad: Bool
|
||||||
|
public let device: [Double]
|
||||||
|
public let expected: PatchColor?
|
||||||
|
public let measured: PatchColor
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
loc: String,
|
||||||
|
isPad: Bool,
|
||||||
|
device: [Double],
|
||||||
|
expected: PatchColor?,
|
||||||
|
measured: PatchColor
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.loc = loc
|
||||||
|
self.isPad = isPad
|
||||||
|
self.device = device
|
||||||
|
self.expected = expected
|
||||||
|
self.measured = measured
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, loc
|
||||||
|
case isPad = "is_pad"
|
||||||
|
case device, expected, measured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour payload carried by `expected` or `measured`.
|
||||||
|
public struct PatchColor: Codable, Sendable, Equatable {
|
||||||
|
public let xyz: CIEXYZ?
|
||||||
|
public let lab: CIELab?
|
||||||
|
public let spectral: SpectralData?
|
||||||
|
|
||||||
|
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
|
||||||
|
self.xyz = xyz
|
||||||
|
self.lab = lab
|
||||||
|
self.spectral = spectral
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case xyz = "XYZ"
|
||||||
|
case lab = "Lab"
|
||||||
|
case spectral = "spectral"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CIEXYZ: Codable, Sendable, Equatable {
|
||||||
|
public let x: Double
|
||||||
|
public let y: Double
|
||||||
|
public let z: Double
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.x = try container.decode(Double.self)
|
||||||
|
self.y = try container.decode(Double.self)
|
||||||
|
self.z = try container.decode(Double.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(x: Double, y: Double, z: Double) {
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.z = z
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(x)
|
||||||
|
try container.encode(y)
|
||||||
|
try container.encode(z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CIELab: Codable, Sendable, Equatable {
|
||||||
|
public let l: Double
|
||||||
|
public let a: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
var container = try decoder.unkeyedContainer()
|
||||||
|
self.l = try container.decode(Double.self)
|
||||||
|
self.a = try container.decode(Double.self)
|
||||||
|
self.b = try container.decode(Double.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(l: Double, a: Double, b: Double) {
|
||||||
|
self.l = l
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.unkeyedContainer()
|
||||||
|
try container.encode(l)
|
||||||
|
try container.encode(a)
|
||||||
|
try container.encode(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SpectralData: Codable, Sendable, Equatable {
|
||||||
|
public let bands: Int
|
||||||
|
public let startNM: Double
|
||||||
|
public let endNM: Double
|
||||||
|
public let norm: Double
|
||||||
|
public let values: [Double]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case bands
|
||||||
|
case startNM = "start_nm"
|
||||||
|
case endNM = "end_nm"
|
||||||
|
case norm
|
||||||
|
case values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete row emitted by `chartread -u`.
|
||||||
|
public struct ChartreadRow: Codable, Sendable, Equatable {
|
||||||
|
public let event: String
|
||||||
|
public let rowId: String
|
||||||
|
public let rowIndex: Int
|
||||||
|
public let totalRows: Int
|
||||||
|
public let patchCount: Int
|
||||||
|
public let patches: [ChartreadPatch]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case event
|
||||||
|
case rowId = "row_id"
|
||||||
|
case rowIndex = "row_index"
|
||||||
|
case totalRows = "total_rows"
|
||||||
|
case patchCount = "patch_count"
|
||||||
|
case patches
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isFinalRow: Bool {
|
||||||
|
rowIndex + 1 >= totalRows
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of evaluating one measured patch.
|
||||||
|
public struct SwatchEvaluation: Sendable, Equatable {
|
||||||
|
public let intended: DisplayRGB
|
||||||
|
public let measured: DisplayRGB
|
||||||
|
public let deltaE: Double?
|
||||||
|
public let classification: SwatchClassification
|
||||||
|
|
||||||
|
public init(
|
||||||
|
intended: DisplayRGB,
|
||||||
|
measured: DisplayRGB,
|
||||||
|
deltaE: Double?,
|
||||||
|
classification: SwatchClassification
|
||||||
|
) {
|
||||||
|
self.intended = intended
|
||||||
|
self.measured = measured
|
||||||
|
self.deltaE = deltaE
|
||||||
|
self.classification = classification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
|
||||||
|
case good
|
||||||
|
case warning
|
||||||
|
case bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIEDE2000 ΔE₀₀ between two D50 Lab values.
|
||||||
|
public enum ColorDifference {
|
||||||
|
|
||||||
|
/// Compute ΔE₀₀ using the full CIEDE2000 formula.
|
||||||
|
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
|
||||||
|
let kL: Double = 1
|
||||||
|
let kC: Double = 1
|
||||||
|
let kH: Double = 1
|
||||||
|
|
||||||
|
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
|
||||||
|
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let cBar = (c1 + c2) / 2.0
|
||||||
|
let cBar7 = pow(cBar, 7)
|
||||||
|
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
|
||||||
|
|
||||||
|
let a1p = (1 + g) * lab1.a
|
||||||
|
let a2p = (1 + g) * lab2.a
|
||||||
|
|
||||||
|
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
|
||||||
|
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
|
||||||
|
|
||||||
|
let h1p = atan2ToDegrees(lab1.b, a1p)
|
||||||
|
let h2p = atan2ToDegrees(lab2.b, a2p)
|
||||||
|
|
||||||
|
let deltaLp = lab2.l - lab1.l
|
||||||
|
let deltaCp = c2p - c1p
|
||||||
|
|
||||||
|
var deltaHp: Double = 0
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
deltaHp = 0
|
||||||
|
} else {
|
||||||
|
let diff = h2p - h1p
|
||||||
|
if abs(diff) <= 180 {
|
||||||
|
deltaHp = diff
|
||||||
|
} else if diff > 180 {
|
||||||
|
deltaHp = diff - 360
|
||||||
|
} else {
|
||||||
|
deltaHp = diff + 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
|
||||||
|
|
||||||
|
let lBarp = (lab1.l + lab2.l) / 2.0
|
||||||
|
let cBarp = (c1p + c2p) / 2.0
|
||||||
|
|
||||||
|
var hBarp: Double
|
||||||
|
if c1p * c2p == 0 {
|
||||||
|
hBarp = h1p + h2p
|
||||||
|
} else {
|
||||||
|
if abs(h1p - h2p) <= 180 {
|
||||||
|
hBarp = (h1p + h2p) / 2.0
|
||||||
|
} else if h1p + h2p < 360 {
|
||||||
|
hBarp = (h1p + h2p + 360) / 2.0
|
||||||
|
} else {
|
||||||
|
hBarp = (h1p + h2p - 360) / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = 1
|
||||||
|
- 0.17 * cos(deg2rad(hBarp - 30))
|
||||||
|
+ 0.24 * cos(deg2rad(2 * hBarp))
|
||||||
|
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
|
||||||
|
- 0.20 * cos(deg2rad(4 * hBarp - 63))
|
||||||
|
|
||||||
|
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
|
||||||
|
let cBarp7 = pow(cBarp, 7)
|
||||||
|
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
|
||||||
|
|
||||||
|
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
|
||||||
|
let sc = 1 + 0.045 * cBarp
|
||||||
|
let sh = 1 + 0.015 * cBarp * t
|
||||||
|
|
||||||
|
let rt = -sin(deg2rad(2 * dTheta)) * rc
|
||||||
|
|
||||||
|
let lTerm = deltaLp / (kL * sl)
|
||||||
|
let cTerm = deltaCp / (kC * sc)
|
||||||
|
let hTerm = deltaHp2 / (kH * sh)
|
||||||
|
|
||||||
|
return sqrt(
|
||||||
|
lTerm * lTerm
|
||||||
|
+ cTerm * cTerm
|
||||||
|
+ hTerm * hTerm
|
||||||
|
+ rt * cTerm * hTerm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a ΔE value against user thresholds.
|
||||||
|
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
|
||||||
|
if deltaE < goodMax { return .good }
|
||||||
|
if deltaE < warningMax { return .warning }
|
||||||
|
return .bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
|
||||||
|
public static func evaluate(
|
||||||
|
patch: ChartreadPatch,
|
||||||
|
goodMax: Double,
|
||||||
|
warningMax: Double
|
||||||
|
) -> SwatchEvaluation? {
|
||||||
|
guard let measured = resolveLab(patch.measured) else { return nil }
|
||||||
|
|
||||||
|
let measuredRGB = LabColorMath.labToSRGB(measured)
|
||||||
|
|
||||||
|
if let expectedColor = patch.expected,
|
||||||
|
let expectedLab = resolveLab(expectedColor) {
|
||||||
|
let de = deltaE00(expectedLab, measured)
|
||||||
|
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: intendedRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: de,
|
||||||
|
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No reference: still render measured colour, no ΔE.
|
||||||
|
return SwatchEvaluation(
|
||||||
|
intended: measuredRGB,
|
||||||
|
measured: measuredRGB,
|
||||||
|
deltaE: nil,
|
||||||
|
classification: .good
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
|
||||||
|
public static func resolveLab(_ color: PatchColor) -> LabColor? {
|
||||||
|
if let lab = color.lab {
|
||||||
|
return LabColor(l: lab.l, a: lab.a, b: lab.b)
|
||||||
|
}
|
||||||
|
guard let xyz = color.xyz else { return nil }
|
||||||
|
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
|
||||||
|
let radians = atan2(y, x)
|
||||||
|
var degrees = radians * 180.0 / .pi
|
||||||
|
if degrees < 0 { degrees += 360 }
|
||||||
|
return degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deg2rad(_ degrees: Double) -> Double {
|
||||||
|
degrees * .pi / 180.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A device discovered by the Argyll `instlist` fork.
|
||||||
|
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
public let port: Int
|
||||||
|
public let name: String
|
||||||
|
public let type: String
|
||||||
|
|
||||||
|
public init(port: Int, name: String, type: String) {
|
||||||
|
self.port = port
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: Int { port }
|
||||||
|
|
||||||
|
/// XY tables are identified by name or type matching the fork pattern.
|
||||||
|
public var isXY: Bool {
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
let pattern = #"/spectro\s?scan|i1io/"#
|
||||||
|
return combined.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label shown in the picker.
|
||||||
|
public var displayName: String {
|
||||||
|
let xyTag = isXY ? " · XY Table" : ""
|
||||||
|
return "\(name) [\(type)]\(xyTag)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user’s choice for a chartread session.
|
||||||
|
public enum InstrumentSelection: Sendable, Equatable {
|
||||||
|
/// Auto / first available port — `chartread` omits `-c`.
|
||||||
|
case auto
|
||||||
|
/// A concrete instrument.
|
||||||
|
case device(InstrumentDevice)
|
||||||
|
|
||||||
|
/// The value to pass to `chartread -c`.
|
||||||
|
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
|
||||||
|
public var chartreadPort: Int? {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return nil
|
||||||
|
case .device(let device):
|
||||||
|
return device.port == 1 ? nil : device.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current selection implies an XY table workflow.
|
||||||
|
public var isXY: Bool {
|
||||||
|
switch self {
|
||||||
|
case .auto:
|
||||||
|
return false
|
||||||
|
case .device(let device):
|
||||||
|
return device.isXY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `instlist` output parsing.
|
||||||
|
public enum InstrumentParserError: Error, Sendable, Equatable {
|
||||||
|
case malformedJSON
|
||||||
|
case missingDevices
|
||||||
|
case invalidPort
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the Argyll `instlist` stdout document.
|
||||||
|
///
|
||||||
|
/// Fork `instlist` emits pretty-printed JSON with the shape
|
||||||
|
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
|
||||||
|
/// If JSON decoding fails, a constrained regex fallback is used.
|
||||||
|
/// Only lines accepted by the fallback must also match known instrument tokens.
|
||||||
|
public enum InstrumentParser {
|
||||||
|
|
||||||
|
/// Known instrument tokens used by the regex fallback.
|
||||||
|
public static let knownInstrumentPattern =
|
||||||
|
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
|
||||||
|
|
||||||
|
/// Parse the complete `instlist` output.
|
||||||
|
public static func parse(_ output: String) throws -> [InstrumentDevice] {
|
||||||
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
|
||||||
|
if let data = trimmed.data(using: .utf8),
|
||||||
|
let decoded = try? decodeJSON(data) {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
|
||||||
|
options: [.caseInsensitive, .anchorsMatchLines]
|
||||||
|
)
|
||||||
|
var devices: [InstrumentDevice] = []
|
||||||
|
let range = NSRange(trimmed.startIndex..., in: trimmed)
|
||||||
|
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let portString = substring(trimmed, range: match.range(at: 1)),
|
||||||
|
let port = Int(portString), port > 0 else { continue }
|
||||||
|
|
||||||
|
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
let combined = "\(name) \(type)".lowercased()
|
||||||
|
guard combined.range(of: knownInstrumentPattern,
|
||||||
|
options: [.regularExpression, .caseInsensitive]) != nil,
|
||||||
|
!name.isEmpty else { continue }
|
||||||
|
|
||||||
|
devices.append(InstrumentDevice(port: port, name: name, type: type))
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
|
||||||
|
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
|
||||||
|
return output.devices
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func substring(_ source: String, range: NSRange) -> String? {
|
||||||
|
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
|
||||||
|
return String(source[r])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstlistOutput: Decodable {
|
||||||
|
let event: String
|
||||||
|
let devices: [InstrumentDevice]
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// XYZ tristimulus values, stored in the 0–100 scale used by the Argyll fork.
|
||||||
|
public struct XYZColor: Sendable, Equatable {
|
||||||
|
public let x: Double
|
||||||
|
public let y: Double
|
||||||
|
public let z: Double
|
||||||
|
|
||||||
|
public init(x: Double, y: Double, z: Double) {
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.z = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIELab value (D50).
|
||||||
|
public struct LabColor: Sendable, Equatable {
|
||||||
|
public let l: Double
|
||||||
|
public let a: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(l: Double, a: Double, b: Double) {
|
||||||
|
self.l = l
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB colour in 0–1 display space.
|
||||||
|
public struct DisplayRGB: Sendable, Equatable {
|
||||||
|
public let r: Double
|
||||||
|
public let g: Double
|
||||||
|
public let b: Double
|
||||||
|
|
||||||
|
public init(r: Double, g: Double, b: Double) {
|
||||||
|
self.r = r
|
||||||
|
self.g = g
|
||||||
|
self.b = b
|
||||||
|
}
|
||||||
|
|
||||||
|
public var clamped: DisplayRGB {
|
||||||
|
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour-space conversions used by the swatch grid.
|
||||||
|
///
|
||||||
|
/// All numeric paths are deterministic and avoid platform colour-management APIs.
|
||||||
|
public enum LabColorMath {
|
||||||
|
|
||||||
|
// Reference white for D50 (0–100 scale).
|
||||||
|
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
|
||||||
|
|
||||||
|
// Reference white for D65 (0–100 scale).
|
||||||
|
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
|
||||||
|
|
||||||
|
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
|
||||||
|
static let bradford = [
|
||||||
|
[ 0.8951, 0.2664, -0.1614],
|
||||||
|
[-0.7502, 1.7135, 0.0367],
|
||||||
|
[ 0.0389, -0.0685, 1.0296]
|
||||||
|
]
|
||||||
|
|
||||||
|
static let bradfordInv = [
|
||||||
|
[ 0.9869929, -0.1470543, 0.1599627],
|
||||||
|
[ 0.4323053, 0.5183603, 0.0492912],
|
||||||
|
[-0.0085287, 0.0400428, 0.9684866]
|
||||||
|
]
|
||||||
|
|
||||||
|
// sRGB D65 matrix (XYZ -> linear sRGB, using 0–100 inputs).
|
||||||
|
static let srgbMatrix = [
|
||||||
|
[ 3.2406, -1.5372, -0.4986],
|
||||||
|
[-0.9689, 1.8758, 0.0415],
|
||||||
|
[ 0.0557, -0.2040, 1.0570]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Convert XYZ (0–100) to CIELab D50.
|
||||||
|
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
|
||||||
|
let f: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta * delta * delta {
|
||||||
|
return pow(t, 1.0 / 3.0)
|
||||||
|
} else {
|
||||||
|
return t / (3 * delta * delta) + 4.0 / 29.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = f(xyz.x / d50White.X)
|
||||||
|
let y = f(xyz.y / d50White.Y)
|
||||||
|
let zr = f(xyz.z / d50White.Z)
|
||||||
|
|
||||||
|
return LabColor(
|
||||||
|
l: 116.0 * y - 16.0,
|
||||||
|
a: 500.0 * (x - y),
|
||||||
|
b: 200.0 * (y - zr)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert CIELab D50 to XYZ (0–100).
|
||||||
|
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
|
||||||
|
let finv: (Double) -> Double = { t in
|
||||||
|
let delta = 6.0 / 29.0
|
||||||
|
if t > delta {
|
||||||
|
return t * t * t
|
||||||
|
} else {
|
||||||
|
return 3 * delta * delta * (t - 4.0 / 29.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let yr = (lab.l + 16.0) / 116.0
|
||||||
|
let xr = yr + lab.a / 500.0
|
||||||
|
let zr = yr - lab.b / 200.0
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: finv(xr) * d50White.X,
|
||||||
|
y: finv(yr) * d50White.Y,
|
||||||
|
z: finv(zr) * d50White.Z
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
|
||||||
|
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
|
||||||
|
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
|
||||||
|
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
|
||||||
|
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
|
||||||
|
|
||||||
|
let scaled = [
|
||||||
|
source[0] * (dstWhite[0] / srcWhite[0]),
|
||||||
|
source[1] * (dstWhite[1] / srcWhite[1]),
|
||||||
|
source[2] * (dstWhite[2] / srcWhite[2])
|
||||||
|
]
|
||||||
|
|
||||||
|
return XYZColor(
|
||||||
|
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
|
||||||
|
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
|
||||||
|
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert XYZ D65 (0–100) to linear sRGB, apply gamma, and clamp.
|
||||||
|
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
|
||||||
|
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
|
||||||
|
// The input is 0–100, so scale by 100 first.
|
||||||
|
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
|
||||||
|
let linear = matrixMultiply(srgbMatrix, scaled)
|
||||||
|
|
||||||
|
func gamma(_ c: Double) -> Double {
|
||||||
|
if c <= 0.0031308 {
|
||||||
|
return 12.92 * c
|
||||||
|
} else {
|
||||||
|
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DisplayRGB(
|
||||||
|
r: gamma(linear[0]),
|
||||||
|
g: gamma(linear[1]),
|
||||||
|
b: gamma(linear[2])
|
||||||
|
).clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete D50 Lab -> display sRGB conversion.
|
||||||
|
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
|
||||||
|
let xyz50 = labToXYZ(lab)
|
||||||
|
let xyz65 = adaptD50ToD65(xyz50)
|
||||||
|
return xyzToSRGB(xyz65)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an Argyll XYZ array (0–100) to Lab and then to sRGB.
|
||||||
|
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
|
||||||
|
guard xyz.count >= 3 else { return nil }
|
||||||
|
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
|
||||||
|
var result = [Double](repeating: 0, count: m.count)
|
||||||
|
for i in 0..<m.count {
|
||||||
|
for j in 0..<v.count {
|
||||||
|
result[i] += m[i][j] * v[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from pass-snapshot, promote, and discovery operations.
|
||||||
|
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
case canonicalMissing(URL)
|
||||||
|
case snapshotFailed(String)
|
||||||
|
case promoteFailed(String)
|
||||||
|
case noPassFiles
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid measurement basename: \(name)"
|
||||||
|
case .canonicalMissing(let url):
|
||||||
|
return "Canonical .ti3 not found: \(url.path)"
|
||||||
|
case .snapshotFailed(let reason):
|
||||||
|
return "Snapshot failed: \(reason)"
|
||||||
|
case .promoteFailed(let reason):
|
||||||
|
return "Promote failed: \(reason)"
|
||||||
|
case .noPassFiles:
|
||||||
|
return "No pass .ti3 snapshots are available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem helpers for multi-pass measurement artefacts.
|
||||||
|
///
|
||||||
|
/// Pass snapshots use 1-based numbering and are tracked as
|
||||||
|
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
|
||||||
|
/// Finish / Average (docs/24 #109, #110).
|
||||||
|
public enum MeasurementArtefacts {
|
||||||
|
|
||||||
|
/// Find all existing pass snapshots in `cwd`, sorted numerically.
|
||||||
|
public static func passSnapshots(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> [URL] {
|
||||||
|
guard let entries = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cwd,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { return [] }
|
||||||
|
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let suffix = "ti3"
|
||||||
|
|
||||||
|
let passes: [(Int, URL)] = entries.compactMap { url in
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
guard url.pathExtension.lowercased() == suffix,
|
||||||
|
name.hasPrefix(prefix)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
guard let number = Int(numberPart), number > 0 else { return nil }
|
||||||
|
return (number, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return passes
|
||||||
|
.sorted { $0.0 < $1.0 }
|
||||||
|
.map { $0.1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next 1-based pass number.
|
||||||
|
public static func nextPassNumber(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) -> Int {
|
||||||
|
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
|
||||||
|
guard let last = existing.last else { return 1 }
|
||||||
|
let name = last.lastPathComponent
|
||||||
|
let prefix = "\(basename)_pass"
|
||||||
|
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
|
||||||
|
return (Int(numberPart) ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
|
||||||
|
///
|
||||||
|
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
|
||||||
|
public static func snapshotPass(
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
guard fileManager.fileExists(atPath: canonical.path) else {
|
||||||
|
throw MeasurementArtefactError.canonicalMissing(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
|
||||||
|
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: canonical, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: pass.path) {
|
||||||
|
try? fileManager.removeItem(at: pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: pass)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: canonical)
|
||||||
|
} catch {
|
||||||
|
// The pass file is durable; canonical removal failure is logged but not fatal.
|
||||||
|
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
|
||||||
|
public static func promotePass(
|
||||||
|
pass: URL,
|
||||||
|
basename: String,
|
||||||
|
cwd: URL,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
|
||||||
|
|
||||||
|
guard fileManager.fileExists(atPath: pass.path) else {
|
||||||
|
throw MeasurementArtefactError.noPassFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: temp.path) {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.copyItem(at: pass, to: temp)
|
||||||
|
} catch {
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: canonical.path) {
|
||||||
|
try? fileManager.removeItem(at: canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.moveItem(at: temp, to: canonical)
|
||||||
|
} catch {
|
||||||
|
try? fileManager.removeItem(at: temp)
|
||||||
|
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical `<basename>.ti3` URL, regardless of existence.
|
||||||
|
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
|
||||||
|
return cwd.appendingPathComponent("\(cleanBasename).ti3")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,53 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail as a single line and clears it.
|
||||||
|
/// Used by `ProcessManager.flushPartialLine` for tools that emit
|
||||||
|
/// progress dots without newlines.
|
||||||
|
public mutating func flushPartial() -> String? {
|
||||||
|
guard !pending.isEmpty else { return nil }
|
||||||
|
var rest = pending
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
if rest.last == 0x0D { rest = rest.dropLast() }
|
||||||
|
let text = Self.decode(rest)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decode(_ bytes: Data.SubSequence) -> String {
|
||||||
|
String(decoding: bytes, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
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. If EOFs never arrive, a watchdog finalizes.
|
||||||
|
/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
|
||||||
|
/// terminating. Hooks are removed once the child finalizes.
|
||||||
|
/// - `killAll` on `NSApplication.willTerminate` and last-window close
|
||||||
|
/// runs all hooks and terminates every child (#147, #149).
|
||||||
|
public actor ProcessManager {
|
||||||
|
|
||||||
|
public 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
|
||||||
|
/// Watchdog that forces finalization if EOFs never arrive.
|
||||||
|
var finalizeTask: Task<Void, Never>?
|
||||||
|
}
|
||||||
|
|
||||||
|
private var children: [String: RunningChild] = [:]
|
||||||
|
/// Processes owned by `runCaptured` (dup detection + kill support).
|
||||||
|
private var captured: [String: Process] = [:]
|
||||||
|
|
||||||
|
/// Hooks run by `kill` before terminating the child.
|
||||||
|
/// Used by `chartread` to park an XY head with `q\n`.
|
||||||
|
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
|
||||||
|
|
||||||
|
/// Ids of currently-running children.
|
||||||
|
public var runningIDs: [String] { Array(children.keys) + captured.keys }
|
||||||
|
|
||||||
|
public func isRunning(_ id: String) -> Bool {
|
||||||
|
children[id] != nil || captured[id] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pre-kill hooks
|
||||||
|
|
||||||
|
/// Register a hook to run before `kill(id:)` terminates the child.
|
||||||
|
/// The hook is removed once the child finalizes.
|
||||||
|
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
|
||||||
|
preKillHooks[id] = hook
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (streaming)
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn (captured)
|
||||||
|
|
||||||
|
/// 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 and set up the termination hand-off before run() so
|
||||||
|
// a very fast exit is never missed (#50, #52).
|
||||||
|
captured[id] = process
|
||||||
|
|
||||||
|
let capturedProcess = process
|
||||||
|
|
||||||
|
// Box is local and synchronised with an NSLock; the @unchecked
|
||||||
|
// Sendable annotation is safe because all access is under the lock.
|
||||||
|
final class Box: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var status: Int32?
|
||||||
|
private var continuation: CheckedContinuation<Int32, Never>?
|
||||||
|
|
||||||
|
/// Try to resume an already-stored continuation with the exit
|
||||||
|
/// status. Returns true if a continuation was resumed.
|
||||||
|
func resume(with status: Int32) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
if let cont = continuation {
|
||||||
|
continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
cont.resume(returning: status)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
self.status = status
|
||||||
|
lock.unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a continuation, returning any status that arrived
|
||||||
|
/// before it. The caller must resume with the returned status.
|
||||||
|
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
|
||||||
|
lock.lock()
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
self.continuation = continuation
|
||||||
|
// A fast exit may have raced past the first nil-check.
|
||||||
|
if let status = status {
|
||||||
|
self.status = nil
|
||||||
|
self.continuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let box = Box()
|
||||||
|
capturedProcess.terminationHandler = { proc in
|
||||||
|
_ = box.resume(with: proc.terminationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
_ = box.resume(with: -1)
|
||||||
|
captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
emit(.error(id: id, message: error.localizedDescription))
|
||||||
|
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the parent write ends so readDataToEndOfFile() gets EOF
|
||||||
|
// as soon as the child exits; the child still has its own copies.
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
|
||||||
|
return await withTaskCancellationHandler {
|
||||||
|
async let outData = Task.detached {
|
||||||
|
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
async let errData = Task.detached {
|
||||||
|
stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
}.value
|
||||||
|
|
||||||
|
let code = await withCheckedContinuation { continuation in
|
||||||
|
if let status = box.store(continuation) {
|
||||||
|
continuation.resume(returning: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (out, err) = await (outData, errData)
|
||||||
|
|
||||||
|
// Emit the real exit code once, regardless of whether kill()
|
||||||
|
// already removed the id from `captured`.
|
||||||
|
_ = captured.removeValue(forKey: id)
|
||||||
|
preKillHooks.removeValue(forKey: id)
|
||||||
|
emit(.exit(id: id, code: code))
|
||||||
|
|
||||||
|
return CapturedResult(
|
||||||
|
stdout: String(decoding: out, as: UTF8.self),
|
||||||
|
stderr: String(decoding: err, as: UTF8.self),
|
||||||
|
exitCode: code
|
||||||
|
)
|
||||||
|
} onCancel: { [weak self] in
|
||||||
|
// If the awaiting Task is cancelled, terminate the child so
|
||||||
|
// callers like runApplycal never replace a good profile with
|
||||||
|
// a truncated tmp.
|
||||||
|
if capturedProcess.isRunning {
|
||||||
|
capturedProcess.terminate()
|
||||||
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.kill(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: - Partial-line flush
|
||||||
|
|
||||||
|
/// Emits the current unterminated tail of a streaming child's stdout
|
||||||
|
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
|
||||||
|
/// to flush progress dots without waiting for a newline.
|
||||||
|
public func flushPartialLine(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if let tail = child.stdoutDecoder.flushPartial() {
|
||||||
|
if tail.hasPrefix(Self.rowColorsPrefix) {
|
||||||
|
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
|
||||||
|
emit(.jsonRow(id: id, payload: payload))
|
||||||
|
} else {
|
||||||
|
emit(.stdout(id: id, line: tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let tail = child.stderrDecoder.flushPartial() {
|
||||||
|
emit(.stderr(id: id, line: tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
children[id] = child
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kill
|
||||||
|
|
||||||
|
/// Terminates a child. First runs any registered pre-kill hook, then
|
||||||
|
/// drops stdin and signals the process. For streaming children the
|
||||||
|
/// `exit` event is emitted once both stdout and stderr EOFs have been
|
||||||
|
/// seen (or the watchdog finalizes). For captured children the real
|
||||||
|
/// exit code is emitted by `runCaptured` itself.
|
||||||
|
public func kill(id: String) async {
|
||||||
|
if let hook = preKillHooks.removeValue(forKey: id) {
|
||||||
|
await hook()
|
||||||
|
}
|
||||||
|
|
||||||
|
if var child = children[id] {
|
||||||
|
try? child.stdin?.close()
|
||||||
|
child.stdin = nil
|
||||||
|
children[id] = child
|
||||||
|
|
||||||
|
if child.process.isRunning {
|
||||||
|
child.process.terminate()
|
||||||
|
} else if child.pendingExitCode == nil {
|
||||||
|
// The process already exited but `didTerminate` has not
|
||||||
|
// run; synthesize it so `maybeFinalize` can fire.
|
||||||
|
didTerminate(id: id, code: child.process.terminationStatus)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let process = captured[id] {
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
// Do not emit `.exit` here; `runCaptured` emits the real code
|
||||||
|
// after the process reaps.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminates every running child; returns how many were signaled
|
||||||
|
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
|
||||||
|
@discardableResult
|
||||||
|
public func killAll() async -> Int {
|
||||||
|
let ids = runningIDs
|
||||||
|
for id in ids { await kill(id: id) }
|
||||||
|
return ids.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Force kill (SIGKILL fallback)
|
||||||
|
|
||||||
|
/// Sends `SIGKILL` to a streaming child if it is still running.
|
||||||
|
/// Used by the finalization watchdog when a graceful `terminate()`
|
||||||
|
/// does not cause the process to exit.
|
||||||
|
public func forceKill(id: String) {
|
||||||
|
guard let child = children[id],
|
||||||
|
!child.finalized,
|
||||||
|
child.process.isRunning
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let pid = child.process.processIdentifier
|
||||||
|
guard pid > 0 else { return }
|
||||||
|
_ = Darwin.kill(pid, SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Start a watchdog in case the `readabilityHandler` EOFs never
|
||||||
|
// arrive after the process exits (e.g. a hung pipe).
|
||||||
|
child.finalizeTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
guard let self else { return }
|
||||||
|
await self.forceKill(id: id)
|
||||||
|
await self.forceFinalize(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
children[id] = child
|
||||||
|
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
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
|
children.removeValue(forKey: id)
|
||||||
|
preKillHooks.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces finalization even when one or both EOFs are missing.
|
||||||
|
/// Used by the `didTerminate` watchdog.
|
||||||
|
private func forceFinalize(id: String) {
|
||||||
|
guard var child = children[id], !child.finalized else { return }
|
||||||
|
|
||||||
|
if child.pendingExitCode == nil {
|
||||||
|
child.pendingExitCode = -9
|
||||||
|
}
|
||||||
|
child.stdoutEOF = true
|
||||||
|
child.stderrEOF = true
|
||||||
|
child.finalizeTask?.cancel()
|
||||||
|
child.finalizeTask = nil
|
||||||
|
children[id] = child
|
||||||
|
maybeFinalize(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `applycal` argv construction.
|
||||||
|
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidCalibrationPath
|
||||||
|
case invalidInputProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidCalibrationPath:
|
||||||
|
return "Calibration path is invalid or empty"
|
||||||
|
case .invalidInputProfileURL:
|
||||||
|
return "Input profile path is invalid or empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `applycal` tool.
|
||||||
|
///
|
||||||
|
/// `applycal` is always run captured, never streamed.
|
||||||
|
public enum ApplycalArgs {
|
||||||
|
|
||||||
|
/// Builds `applycal -v -a {cal} {input} [{output}]`.
|
||||||
|
///
|
||||||
|
/// `-u` (unapply) is rejected at the builder level — the UI never
|
||||||
|
/// sends it (docs/04 §7.2).
|
||||||
|
public static func build(config: ApplycalConfig) throws -> [String] {
|
||||||
|
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
|
||||||
|
|
||||||
|
let input = config.inputProfileURL.path
|
||||||
|
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
if config.unapply {
|
||||||
|
// Defensive: should never be called from the UI.
|
||||||
|
args.append("-u")
|
||||||
|
} else {
|
||||||
|
args.append("-a")
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(contentsOf: [cal, input])
|
||||||
|
|
||||||
|
if let output = config.outputProfileURL?.path, !output.isEmpty {
|
||||||
|
args.append(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `applycal` run.
|
||||||
|
public struct ApplycalConfig: Sendable, Equatable {
|
||||||
|
public var calibrationPath: String
|
||||||
|
public var inputProfileURL: URL
|
||||||
|
public var outputProfileURL: URL?
|
||||||
|
public var unapply: Bool
|
||||||
|
|
||||||
|
/// In-place when `outputProfileURL` is `nil`.
|
||||||
|
public init(
|
||||||
|
calibrationPath: String,
|
||||||
|
inputProfileURL: URL,
|
||||||
|
outputProfileURL: URL? = nil,
|
||||||
|
unapply: Bool = false
|
||||||
|
) {
|
||||||
|
self.calibrationPath = calibrationPath
|
||||||
|
self.inputProfileURL = inputProfileURL
|
||||||
|
self.outputProfileURL = outputProfileURL
|
||||||
|
self.unapply = unapply
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `colprof` argv construction.
|
||||||
|
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidBasename(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBasename(let name):
|
||||||
|
return "Invalid colprof basename: \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `colprof` tool.
|
||||||
|
public enum ColprofArgs {
|
||||||
|
|
||||||
|
/// Builds `colprof` argv per the Gronod fork protocol.
|
||||||
|
///
|
||||||
|
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
|
||||||
|
/// only when their fields are non-empty and meaningful. `-f` has
|
||||||
|
/// special handling for "none" (omit), "" (bare flag), and a custom
|
||||||
|
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
|
||||||
|
/// skipped when set to "none".
|
||||||
|
public static func build(config: ColprofConfig) throws -> [String] {
|
||||||
|
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
|
||||||
|
|
||||||
|
var args: [String] = ["-v"]
|
||||||
|
|
||||||
|
args.append(contentsOf: ["-a", config.algorithm])
|
||||||
|
args.append(contentsOf: ["-q", config.quality])
|
||||||
|
|
||||||
|
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
|
||||||
|
args.append(contentsOf: ["-t", intent])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
|
||||||
|
switch fwa.lowercased() {
|
||||||
|
case "none", "":
|
||||||
|
// "none" omits the flag; an explicit empty string means bare -f.
|
||||||
|
if fwa.isEmpty {
|
||||||
|
args.append("-f")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
args.append(contentsOf: ["-f", fwa])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
|
||||||
|
args.append(contentsOf: ["-i", illuminant])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
|
||||||
|
args.append(contentsOf: ["-o", observer])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!inputCond.isEmpty, inputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-c", inputCond])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
|
||||||
|
!outputCond.isEmpty, outputCond.lowercased() != "none" {
|
||||||
|
args.append(contentsOf: ["-d", outputCond])
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
|
||||||
|
if let description = profileDescription, !description.isEmpty {
|
||||||
|
args.append(contentsOf: ["-D", description])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
|
||||||
|
args.append(contentsOf: ["-C", copyright])
|
||||||
|
}
|
||||||
|
|
||||||
|
args.append(cleanBasename)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
|
||||||
|
public struct ColprofConfig: Sendable, Equatable {
|
||||||
|
public var algorithm: String
|
||||||
|
public var quality: String
|
||||||
|
public var intent: String?
|
||||||
|
public var fwa: String?
|
||||||
|
public var illuminant: String?
|
||||||
|
public var observer: String?
|
||||||
|
public var inputViewingCond: String?
|
||||||
|
public var outputViewingCond: String?
|
||||||
|
public var description: String?
|
||||||
|
public var copyright: String?
|
||||||
|
public var basename: String
|
||||||
|
public var workingDirectory: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
algorithm: String = "l",
|
||||||
|
quality: String = "m",
|
||||||
|
intent: String? = nil,
|
||||||
|
fwa: String? = nil,
|
||||||
|
illuminant: String? = nil,
|
||||||
|
observer: String? = nil,
|
||||||
|
inputViewingCond: String? = nil,
|
||||||
|
outputViewingCond: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
copyright: String? = nil,
|
||||||
|
basename: String,
|
||||||
|
workingDirectory: URL? = nil
|
||||||
|
) {
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.quality = quality
|
||||||
|
self.intent = intent
|
||||||
|
self.fwa = fwa
|
||||||
|
self.illuminant = illuminant
|
||||||
|
self.observer = observer
|
||||||
|
self.inputViewingCond = inputViewingCond
|
||||||
|
self.outputViewingCond = outputViewingCond
|
||||||
|
self.description = description
|
||||||
|
self.copyright = copyright
|
||||||
|
self.basename = basename
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Classified `colprof` stdout progress milestone.
|
||||||
|
public enum ColprofProgress: Sendable, Equatable {
|
||||||
|
case gamutMapping
|
||||||
|
case fittingClut
|
||||||
|
case writingIcc
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `colprof` plaintext progress (docs/16 §6.3).
|
||||||
|
///
|
||||||
|
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
|
||||||
|
/// Progress is therefore inferred from case-insensitive substring matches.
|
||||||
|
public enum ColprofProgressClassifier {
|
||||||
|
|
||||||
|
public static func classify(line: String) -> ColprofProgress {
|
||||||
|
let lower = line.lowercased()
|
||||||
|
if lower.contains("gamut mapping") {
|
||||||
|
return .gamutMapping
|
||||||
|
}
|
||||||
|
if lower.contains("fitting") || lower.contains("clut") {
|
||||||
|
return .fittingClut
|
||||||
|
}
|
||||||
|
if lower.contains("writing") || lower.contains("icc profile") {
|
||||||
|
return .writingIcc
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Computes a consecutive-breach warning from verification history.
|
||||||
|
///
|
||||||
|
/// A drift alert triggers when the most recent chronologically consecutive
|
||||||
|
/// poor records form a run of at least two, and the first and last of that
|
||||||
|
/// run are on distinct UTC days or at least one hour apart.
|
||||||
|
public enum DriftAlert {
|
||||||
|
|
||||||
|
/// Returns an alert message, or `nil` when no consecutive breach exists.
|
||||||
|
public static func compute(from records: [VerificationRecord]) -> String? {
|
||||||
|
// Work in chronological order.
|
||||||
|
let chronological = records.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
|
||||||
|
// Build the longest suffix of consecutive `.poor` records.
|
||||||
|
// Non-poor records break the run, so we stop at the first non-poor
|
||||||
|
// encountered from the end.
|
||||||
|
var run: [VerificationRecord] = []
|
||||||
|
for record in chronological.reversed() {
|
||||||
|
if record.status == .poor {
|
||||||
|
run.insert(record, at: 0)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard run.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let first = run.first!
|
||||||
|
let last = run.last!
|
||||||
|
|
||||||
|
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
|
||||||
|
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
|
||||||
|
|
||||||
|
if !sameDay || oneHour {
|
||||||
|
return "Drift alert: poor results between \(first.id) and \(last.id)."
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Calendar {
|
||||||
|
static let utc: Calendar = {
|
||||||
|
var c = Calendar(identifier: .iso8601)
|
||||||
|
c.timeZone = TimeZone(identifier: "UTC")!
|
||||||
|
return c
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `iccgamut` argv construction.
|
||||||
|
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case invalidProfileURL
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidProfileURL:
|
||||||
|
return "iccgamut requires a valid profile path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `iccgamut` tool.
|
||||||
|
public enum IccgamutArgs {
|
||||||
|
|
||||||
|
/// Builds `iccgamut -v -d {density} {profilePath}`.
|
||||||
|
///
|
||||||
|
/// The caller is responsible for ensuring `density` is a positive
|
||||||
|
/// integer. `-d` here is surface **density**, not a directory.
|
||||||
|
public static func build(config: IccgamutConfig) throws -> [String] {
|
||||||
|
let path = config.profileURL.path
|
||||||
|
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
|
||||||
|
|
||||||
|
let density = max(1, config.density)
|
||||||
|
return ["-v", "-d", "\(density)", path]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `iccgamut` run.
|
||||||
|
public struct IccgamutConfig: Sendable, Equatable {
|
||||||
|
public var profileURL: URL
|
||||||
|
public var density: Int
|
||||||
|
|
||||||
|
public init(profileURL: URL, density: Int = 10) {
|
||||||
|
self.profileURL = profileURL
|
||||||
|
self.density = density
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for a profile installation.
|
||||||
|
public struct InstallProfileConfig: Sendable, Equatable {
|
||||||
|
public var sourceURL: URL
|
||||||
|
public var options: InstallProfileOptions
|
||||||
|
|
||||||
|
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Collision policy for profile installation.
|
||||||
|
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case overwrite
|
||||||
|
case rename
|
||||||
|
case cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for installing a finished profile into the OS colour store.
|
||||||
|
public struct InstallProfileOptions: Sendable, Equatable, Codable {
|
||||||
|
public var forceOverwrite: Bool
|
||||||
|
public var preferSystem: Bool
|
||||||
|
public var collisionPolicy: ProfileCollisionPolicy
|
||||||
|
public var openColorPanel: Bool
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
forceOverwrite: Bool = false,
|
||||||
|
preferSystem: Bool = false,
|
||||||
|
collisionPolicy: ProfileCollisionPolicy = .cancel,
|
||||||
|
openColorPanel: Bool = false,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.forceOverwrite = forceOverwrite
|
||||||
|
self.preferSystem = preferSystem
|
||||||
|
self.collisionPolicy = collisionPolicy
|
||||||
|
self.openColorPanel = openColorPanel
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of installing a profile into the OS colour store.
|
||||||
|
public struct InstallProfileResult: Sendable, Equatable, Codable {
|
||||||
|
public var destPath: String
|
||||||
|
public var registered: Bool
|
||||||
|
public var overwritten: Bool
|
||||||
|
public var renamed: Bool
|
||||||
|
public var openedPanel: Bool
|
||||||
|
public var message: String
|
||||||
|
public var calibrationNote: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
destPath: String,
|
||||||
|
registered: Bool,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool,
|
||||||
|
openedPanel: Bool,
|
||||||
|
message: String,
|
||||||
|
calibrationNote: String? = nil
|
||||||
|
) {
|
||||||
|
self.destPath = destPath
|
||||||
|
self.registered = registered
|
||||||
|
self.overwritten = overwritten
|
||||||
|
self.renamed = renamed
|
||||||
|
self.openedPanel = openedPanel
|
||||||
|
self.message = message
|
||||||
|
self.calibrationNote = calibrationNote
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors during `profcheck` argv construction.
|
||||||
|
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
|
||||||
|
case missingTi3
|
||||||
|
case missingIcc
|
||||||
|
case invalidTi3Path
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingTi3:
|
||||||
|
return "profcheck requires a .ti3 file"
|
||||||
|
case .missingIcc:
|
||||||
|
return "profcheck requires a profile (.icc/.icm)"
|
||||||
|
case .invalidTi3Path:
|
||||||
|
return "profcheck .ti3 path is invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure argv builder for Argyll's `profcheck` tool.
|
||||||
|
public enum ProfcheckArgs {
|
||||||
|
|
||||||
|
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
|
||||||
|
///
|
||||||
|
/// The `-u` here is the Gronod fork JSON report flag, not the
|
||||||
|
/// generic `-u` auto-fix that some Argyll builds use.
|
||||||
|
public static func build(config: ProfcheckConfig) throws -> [String] {
|
||||||
|
let ti3Path = config.ti3URL.path
|
||||||
|
let iccPath = config.iccURL.path
|
||||||
|
|
||||||
|
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
|
||||||
|
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
|
||||||
|
|
||||||
|
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Configuration for an Argyll `profcheck` run.
|
||||||
|
public struct ProfcheckConfig: Sendable, Equatable {
|
||||||
|
public var ti3URL: URL
|
||||||
|
public var iccURL: URL
|
||||||
|
|
||||||
|
public init(ti3URL: URL, iccURL: URL) {
|
||||||
|
self.ti3URL = ti3URL
|
||||||
|
self.iccURL = iccURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors returned when `profcheck` output cannot be parsed.
|
||||||
|
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unparseable
|
||||||
|
case jsonDecodingFailed
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unparseable:
|
||||||
|
return "Could not parse profcheck report"
|
||||||
|
case .jsonDecodingFailed:
|
||||||
|
return "profcheck JSON report could not be decoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
|
||||||
|
public enum ProfcheckParser {
|
||||||
|
|
||||||
|
/// Parsing order (issue #25):
|
||||||
|
/// 1. Patch count from `No of test patches = N`.
|
||||||
|
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
|
||||||
|
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
|
||||||
|
/// 4. Broad regex fallback.
|
||||||
|
/// 5. If no metrics found, return a report whose `warning` is set.
|
||||||
|
public static func parse(_ output: String) -> ProfcheckReport {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
// 1. Patch count.
|
||||||
|
let patchRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"No of test patches\s*=\s*(\d+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = patchRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
), let range = Range(match.range(at: 1), in: output) {
|
||||||
|
let count = Int(output[range])
|
||||||
|
report.patchCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JSON objects.
|
||||||
|
let jsonObjects = extractJSONObjects(from: output)
|
||||||
|
for object in jsonObjects {
|
||||||
|
if let event = object["event"] as? String, event == "report" {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasMetricKeys(object) {
|
||||||
|
if let parsed = metrics(from: object) {
|
||||||
|
apply(metrics: parsed, to: &report)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Legacy text.
|
||||||
|
let textRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
if let match = textRegex?.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
) {
|
||||||
|
let numbers = (1...3).compactMap { i -> Double? in
|
||||||
|
guard let range = Range(match.range(at: i), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
if numbers.count == 3 {
|
||||||
|
report.maxDE = numbers[0]
|
||||||
|
report.avgDE = numbers[1]
|
||||||
|
report.rmsDE = numbers[2]
|
||||||
|
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Broad regex fallback.
|
||||||
|
if let fallback = parseRegexFallback(output) {
|
||||||
|
var merged = fallback
|
||||||
|
merged.patchCount = report.patchCount
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unparseable.
|
||||||
|
report.warning = "profcheck output did not contain a recognisable report."
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON extraction
|
||||||
|
|
||||||
|
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
|
||||||
|
var objects: [[String: Any]] = []
|
||||||
|
var start: String.Index?
|
||||||
|
var depth = 0
|
||||||
|
|
||||||
|
for index in output.indices {
|
||||||
|
let char = output[index]
|
||||||
|
if char == "{" {
|
||||||
|
if depth == 0 {
|
||||||
|
start = index
|
||||||
|
}
|
||||||
|
depth += 1
|
||||||
|
} else if char == "}" {
|
||||||
|
if depth > 0 {
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0, let start = start {
|
||||||
|
let jsonString = String(output[start...index])
|
||||||
|
if let data = jsonString.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
objects.append(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
|
||||||
|
let keys = [
|
||||||
|
"avg_de", "avg_de2000",
|
||||||
|
"peak_de", "peak_de2000", "max_de",
|
||||||
|
"rms", "rms_de"
|
||||||
|
]
|
||||||
|
return keys.contains { object[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metrics {
|
||||||
|
var avg: Double?
|
||||||
|
var max: Double?
|
||||||
|
var rms: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func metrics(from object: [String: Any]) -> Metrics? {
|
||||||
|
var m = Metrics()
|
||||||
|
m.avg = doubleValue(for: "avg_de2000", in: object)
|
||||||
|
?? doubleValue(for: "avg_de", in: object)
|
||||||
|
m.max = doubleValue(for: "peak_de2000", in: object)
|
||||||
|
?? doubleValue(for: "peak_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de", in: object)
|
||||||
|
?? doubleValue(for: "max_de2000", in: object)
|
||||||
|
m.rms = doubleValue(for: "rms", in: object)
|
||||||
|
?? doubleValue(for: "rms_de", in: object)
|
||||||
|
?? doubleValue(for: "rms_de2000", in: object)
|
||||||
|
|
||||||
|
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
|
||||||
|
if let number = object[key] as? Double { return number }
|
||||||
|
if let number = object[key] as? NSNumber { return number.doubleValue }
|
||||||
|
if let string = object[key] as? String { return Double(string) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
|
||||||
|
report.avgDE = metrics.avg
|
||||||
|
report.maxDE = metrics.max
|
||||||
|
report.rmsDE = metrics.rms
|
||||||
|
if let avg = metrics.avg {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Regex fallback
|
||||||
|
|
||||||
|
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
|
||||||
|
var report = ProfcheckReport()
|
||||||
|
|
||||||
|
let avgRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let maxRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
let rmsRegex = try? NSRegularExpression(
|
||||||
|
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
)
|
||||||
|
|
||||||
|
report.avgDE = firstDouble(from: output, regex: avgRegex)
|
||||||
|
report.maxDE = firstDouble(from: output, regex: maxRegex)
|
||||||
|
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
|
||||||
|
|
||||||
|
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let avg = report.avgDE {
|
||||||
|
report.status = VerificationStatus.from(avgDE: avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
|
||||||
|
guard let regex = regex,
|
||||||
|
let match = regex.firstMatch(
|
||||||
|
in: output,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(output.startIndex..., in: output)
|
||||||
|
),
|
||||||
|
let range = Range(match.range(at: 1), in: output) else { return nil }
|
||||||
|
return Double(output[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed result from a `profcheck -u` run.
|
||||||
|
public struct ProfcheckReport: Sendable, Equatable, Codable {
|
||||||
|
public var patchCount: Int?
|
||||||
|
public var avgDE: Double?
|
||||||
|
public var maxDE: Double?
|
||||||
|
public var rmsDE: Double?
|
||||||
|
public var status: VerificationStatus?
|
||||||
|
public var warning: String?
|
||||||
|
|
||||||
|
public var isValid: Bool {
|
||||||
|
avgDE != nil && maxDE != nil && rmsDE != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
patchCount: Int? = nil,
|
||||||
|
avgDE: Double? = nil,
|
||||||
|
maxDE: Double? = nil,
|
||||||
|
rmsDE: Double? = nil,
|
||||||
|
status: VerificationStatus? = nil,
|
||||||
|
warning: String? = nil
|
||||||
|
) {
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.status = status
|
||||||
|
self.warning = warning
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown by `ProfileInstaller`.
|
||||||
|
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
|
||||||
|
case unsafeStem(String)
|
||||||
|
case sourceMissing
|
||||||
|
case sourceNotProfile
|
||||||
|
case sourceTooSmall
|
||||||
|
case systemRequiresAdminRights
|
||||||
|
case copyFailed(String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsafeStem(let stem):
|
||||||
|
return "Profile name contains unsafe characters: \(stem)"
|
||||||
|
case .sourceMissing:
|
||||||
|
return "Source profile does not exist"
|
||||||
|
case .sourceNotProfile:
|
||||||
|
return "Source must be a .icc or .icm file"
|
||||||
|
case .sourceTooSmall:
|
||||||
|
return "Source file is too small to be a valid profile"
|
||||||
|
case .systemRequiresAdminRights:
|
||||||
|
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
|
||||||
|
case .copyFailed(let reason):
|
||||||
|
return "Could not install profile: \(reason)"
|
||||||
|
case .cancelled:
|
||||||
|
return "Install cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs an ICC/ICM profile into the OS colour store.
|
||||||
|
public enum ProfileInstaller {
|
||||||
|
|
||||||
|
/// Resolves the destination URL that `install` would write to for the
|
||||||
|
/// given source and options, without copying anything. Useful for
|
||||||
|
/// collision previews in the UI.
|
||||||
|
public static func resolveDestinationURL(
|
||||||
|
for config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> URL {
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
|
||||||
|
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
|
||||||
|
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
|
||||||
|
public static func install(
|
||||||
|
config: InstallProfileConfig,
|
||||||
|
fileManager: FileManager = .default
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
|
||||||
|
// Source validation.
|
||||||
|
let sourceURL = config.sourceURL
|
||||||
|
guard fm.fileExists(atPath: sourceURL.path) else {
|
||||||
|
throw ProfileInstallError.sourceMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = sourceURL.pathExtension.lowercased()
|
||||||
|
guard ext == "icc" || ext == "icm" else {
|
||||||
|
throw ProfileInstallError.sourceNotProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
|
||||||
|
let size = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard size >= 128 else {
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
try validateSourceURL(sourceURL)
|
||||||
|
|
||||||
|
// Destination directory.
|
||||||
|
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
|
||||||
|
try? fm.createDirectory(
|
||||||
|
at: destURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collision resolution.
|
||||||
|
let destExists = fm.fileExists(atPath: destURL.path)
|
||||||
|
if destExists {
|
||||||
|
if config.options.forceOverwrite {
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: true,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .rename {
|
||||||
|
let epoch = Int(Date().timeIntervalSince1970)
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
let renamedURL = destURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: renamedURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: true
|
||||||
|
)
|
||||||
|
} else if config.options.collisionPolicy == .cancel {
|
||||||
|
throw ProfileInstallError.cancelled
|
||||||
|
} else {
|
||||||
|
// Default with askBeforeOverwrite — the app must decide.
|
||||||
|
throw ProfileInstallError.copyFailed("destination already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try performInstall(
|
||||||
|
from: sourceURL,
|
||||||
|
to: destURL,
|
||||||
|
options: config.options,
|
||||||
|
fileManager: fm,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private static func validateSourceURL(_ sourceURL: URL) throws {
|
||||||
|
let path = sourceURL.path
|
||||||
|
let stem = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
|
||||||
|
// Reject backslashes anywhere in the path.
|
||||||
|
guard !path.contains("\\") else {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject any path component that is literally "." or "..".
|
||||||
|
// This allows names like "foo..bar" while blocking real traversal.
|
||||||
|
for component in sourceURL.pathComponents {
|
||||||
|
if component == "." || component == ".." {
|
||||||
|
throw ProfileInstallError.unsafeStem(stem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func destinationDirectory(
|
||||||
|
for options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager
|
||||||
|
) -> URL {
|
||||||
|
if options.preferSystem {
|
||||||
|
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
|
||||||
|
} else {
|
||||||
|
return fileManager.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/ColorSync/Profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func performInstall(
|
||||||
|
from sourceURL: URL,
|
||||||
|
to destURL: URL,
|
||||||
|
options: InstallProfileOptions,
|
||||||
|
fileManager: FileManager,
|
||||||
|
overwritten: Bool,
|
||||||
|
renamed: Bool
|
||||||
|
) throws -> InstallProfileResult {
|
||||||
|
let fm = fileManager
|
||||||
|
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
|
||||||
|
|
||||||
|
// Remove stale tmp.
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fm.copyItem(at: sourceURL, to: tmpURL)
|
||||||
|
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
|
||||||
|
let tmpSize = attrs?[.size] as? UInt64 ?? 0
|
||||||
|
guard tmpSize >= 128 else {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
throw ProfileInstallError.sourceTooSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: destURL.path) {
|
||||||
|
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: tmpURL, to: destURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: tmpURL)
|
||||||
|
|
||||||
|
// Surface a clear admin-rights hint when writing to system.
|
||||||
|
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
|
||||||
|
throw ProfileInstallError.systemRequiresAdminRights
|
||||||
|
}
|
||||||
|
|
||||||
|
if let installError = error as? ProfileInstallError {
|
||||||
|
throw installError
|
||||||
|
}
|
||||||
|
throw ProfileInstallError.copyFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registered = fm.fileExists(atPath: destURL.path)
|
||||||
|
|
||||||
|
var openedPanel = false
|
||||||
|
if options.openColorPanel {
|
||||||
|
openedPanel = openColorSyncUtility()
|
||||||
|
}
|
||||||
|
|
||||||
|
return InstallProfileResult(
|
||||||
|
destPath: destURL.path,
|
||||||
|
registered: registered,
|
||||||
|
overwritten: overwritten,
|
||||||
|
renamed: renamed,
|
||||||
|
openedPanel: openedPanel,
|
||||||
|
message: "Profile installed to \(destURL.path)",
|
||||||
|
calibrationNote: options.calibrationNote
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func openColorSyncUtility() -> Bool {
|
||||||
|
let task = Process()
|
||||||
|
task.launchPath = "/usr/bin/open"
|
||||||
|
task.arguments = ["-a", "ColorSync Utility"]
|
||||||
|
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
|
||||||
|
do {
|
||||||
|
try task.run()
|
||||||
|
task.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persistence for `VerificationRecord` entries.
|
||||||
|
public actor VerificationHistoryStore {
|
||||||
|
|
||||||
|
/// Default cap.
|
||||||
|
public static let defaultCapacity = 1000
|
||||||
|
|
||||||
|
/// Path to the JSON store.
|
||||||
|
public let url: URL
|
||||||
|
|
||||||
|
/// In-memory cache, kept in sync with disk.
|
||||||
|
private var records: [VerificationRecord] = []
|
||||||
|
|
||||||
|
private let capacity: Int
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
|
||||||
|
capacity: Int = defaultCapacity
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.capacity = capacity
|
||||||
|
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
self.encoder.dateEncodingStrategy = .iso8601
|
||||||
|
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.decoder.dateDecodingStrategy = .iso8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads records from disk. Returns the existing cache if already loaded.
|
||||||
|
///
|
||||||
|
/// Throws when the file exists but cannot be parsed; the existing file
|
||||||
|
/// is never overwritten in that case.
|
||||||
|
public func load() throws -> [VerificationRecord] {
|
||||||
|
guard records.isEmpty else { return records }
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: url.path),
|
||||||
|
let data = try? Data(contentsOf: url) else { return [] }
|
||||||
|
records = try decoder.decode([VerificationRecord].self, from: data)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all records.
|
||||||
|
public func all() -> [VerificationRecord] {
|
||||||
|
records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records matching the optional printer filter.
|
||||||
|
public func filtered(by printer: String?) -> [VerificationRecord] {
|
||||||
|
guard let printer = printer, !printer.isEmpty else { return records }
|
||||||
|
return records.filter { $0.printerName == printer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a record, trims to capacity, and writes atomically.
|
||||||
|
///
|
||||||
|
/// Loads the existing history first and propagates any load error so an
|
||||||
|
/// unparseable file is never overwritten.
|
||||||
|
@discardableResult
|
||||||
|
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
|
||||||
|
try load()
|
||||||
|
|
||||||
|
var updated = records
|
||||||
|
updated.append(record)
|
||||||
|
if updated.count > capacity {
|
||||||
|
updated.sort { $0.timestamp < $1.timestamp }
|
||||||
|
updated = Array(updated.suffix(capacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
try write(updated)
|
||||||
|
records = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes all history and updates disk.
|
||||||
|
public func clear() throws {
|
||||||
|
try write([])
|
||||||
|
records = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC-4180 CSV export.
|
||||||
|
public func exportCSV() -> String {
|
||||||
|
var lines: [String] = [
|
||||||
|
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
|
||||||
|
]
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
lines.append(csvRow([
|
||||||
|
record.id,
|
||||||
|
record.profileName,
|
||||||
|
record.printerName,
|
||||||
|
String(record.avgDE),
|
||||||
|
String(record.maxDE),
|
||||||
|
String(record.rmsDE),
|
||||||
|
String(record.patchCount),
|
||||||
|
record.status.rawValue,
|
||||||
|
ISO8601DateFormatter().string(from: record.timestamp)
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `records` through a temp file and rename.
|
||||||
|
private func write(_ records: [VerificationRecord]) throws {
|
||||||
|
let data = try encoder.encode(records)
|
||||||
|
try AtomicFileWriter.write(data, to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func csvRow(_ fields: [String]) -> String {
|
||||||
|
fields.map { field in
|
||||||
|
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
|
||||||
|
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
}.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single entry in the verification history store.
|
||||||
|
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
|
||||||
|
public var id: String
|
||||||
|
public var profileName: String
|
||||||
|
public var printerName: String
|
||||||
|
public var avgDE: Double
|
||||||
|
public var maxDE: Double
|
||||||
|
public var rmsDE: Double
|
||||||
|
public var patchCount: Int
|
||||||
|
public var status: VerificationStatus
|
||||||
|
public var timestamp: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
profileName: String,
|
||||||
|
printerName: String,
|
||||||
|
avgDE: Double,
|
||||||
|
maxDE: Double,
|
||||||
|
rmsDE: Double,
|
||||||
|
patchCount: Int,
|
||||||
|
status: VerificationStatus,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.profileName = profileName
|
||||||
|
self.printerName = printerName
|
||||||
|
self.avgDE = avgDE
|
||||||
|
self.maxDE = maxDE
|
||||||
|
self.rmsDE = rmsDE
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.status = status
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// ICCery quality band for a verification run.
|
||||||
|
///
|
||||||
|
/// Bands are on the **average** ΔE₀₀:
|
||||||
|
/// - < 1.0 → Excellent
|
||||||
|
/// - < 2.0 → Good
|
||||||
|
/// - < 3.5 → Acceptable
|
||||||
|
/// - ≥ 3.5 → Warning
|
||||||
|
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
|
||||||
|
case excellent
|
||||||
|
case good
|
||||||
|
case acceptable
|
||||||
|
case poor
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .excellent: return "Excellent"
|
||||||
|
case .good: return "Good"
|
||||||
|
case .acceptable: return "Acceptable"
|
||||||
|
case .poor: return "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the quality band for the given average ΔE₀₀.
|
||||||
|
public static func from(avgDE: Double) -> VerificationStatus {
|
||||||
|
if avgDE < 1.0 { return .excellent }
|
||||||
|
if avgDE < 2.0 { return .good }
|
||||||
|
if avgDE < 3.5 { return .acceptable }
|
||||||
|
return .poor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained
|
||||||
|
/// solely to decode and migrate pre-M2 `settings.json`; new code uses
|
||||||
|
/// `ProfilingPreset` (docs/22 §ProfilingPreset).
|
||||||
|
public struct CustomPreset: Codable, Equatable, Sendable {
|
||||||
|
public var name: String
|
||||||
|
/// Opaque per-stage form values — keyed by field id.
|
||||||
|
public var values: [String: String]
|
||||||
|
|
||||||
|
public init(name: String, values: [String: String] = [:]) {
|
||||||
|
self.name = name
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `install_profile` drops finished profiles (docs/22).
|
||||||
|
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
|
||||||
|
case user
|
||||||
|
case system
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `settings.json` model (docs/22). snake_case keys match the v1 file
|
||||||
|
/// so field names stay identical across rewrites.
|
||||||
|
///
|
||||||
|
/// Decoding is tolerant: missing keys take documented defaults and each
|
||||||
|
/// `custom_presets` element is tried as a typed `ProfilingPreset` first
|
||||||
|
/// and as a legacy M1 `CustomPreset` second — a malformed entry never
|
||||||
|
/// drops the rest of the array (preset migration, issue #11).
|
||||||
|
public struct AppSettings: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// User override for Argyll binaries; `nil` → bundled sidecars.
|
||||||
|
public var argyllBinaryDir: String?
|
||||||
|
|
||||||
|
/// Stored but **never applied to argv** — Stage 2's own instrument
|
||||||
|
/// select is the live source (docs/04 §0.1).
|
||||||
|
public var defaultInstrument: String?
|
||||||
|
|
||||||
|
/// `nil` → `.debug` in debug builds, `.info` in release (#158).
|
||||||
|
public var logLevel: LogLevel?
|
||||||
|
|
||||||
|
public var deltaEGoodMax: Double
|
||||||
|
public var deltaEWarningMax: Double
|
||||||
|
public var customPresets: [ProfilingPreset]
|
||||||
|
public var enableI1Pro2Leds: Bool
|
||||||
|
public var calibrationStaleDays: Int
|
||||||
|
public var defaultInstallLocation: InstallLocation
|
||||||
|
public var askBeforeOverwriteProfile: Bool
|
||||||
|
public var openColorPanelAfterInstall: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
argyllBinaryDir: String? = nil,
|
||||||
|
defaultInstrument: String? = nil,
|
||||||
|
logLevel: LogLevel? = nil,
|
||||||
|
deltaEGoodMax: Double = 2.0,
|
||||||
|
deltaEWarningMax: Double = 5.0,
|
||||||
|
customPresets: [ProfilingPreset] = [],
|
||||||
|
enableI1Pro2Leds: Bool = false,
|
||||||
|
calibrationStaleDays: Int = 30,
|
||||||
|
defaultInstallLocation: InstallLocation = .user,
|
||||||
|
askBeforeOverwriteProfile: Bool = true,
|
||||||
|
openColorPanelAfterInstall: Bool = false
|
||||||
|
) {
|
||||||
|
self.argyllBinaryDir = argyllBinaryDir
|
||||||
|
self.defaultInstrument = defaultInstrument
|
||||||
|
self.logLevel = logLevel
|
||||||
|
self.deltaEGoodMax = deltaEGoodMax
|
||||||
|
self.deltaEWarningMax = deltaEWarningMax
|
||||||
|
self.customPresets = customPresets
|
||||||
|
self.enableI1Pro2Leds = enableI1Pro2Leds
|
||||||
|
self.calibrationStaleDays = calibrationStaleDays
|
||||||
|
self.defaultInstallLocation = defaultInstallLocation
|
||||||
|
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
|
||||||
|
self.openColorPanelAfterInstall = openColorPanelAfterInstall
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = AppSettings()
|
||||||
|
|
||||||
|
/// Effective log level — runtime state, not just persistence (#158).
|
||||||
|
public var effectiveLogLevel: LogLevel {
|
||||||
|
if let logLevel { return logLevel }
|
||||||
|
#if DEBUG
|
||||||
|
return .debug
|
||||||
|
#else
|
||||||
|
return .info
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case argyllBinaryDir = "argyll_binary_dir"
|
||||||
|
case defaultInstrument = "default_instrument"
|
||||||
|
case logLevel = "log_level"
|
||||||
|
case deltaEGoodMax = "delta_e_good_max"
|
||||||
|
case deltaEWarningMax = "delta_e_warning_max"
|
||||||
|
case customPresets = "custom_presets"
|
||||||
|
case enableI1Pro2Leds = "enable_i1pro2_leds"
|
||||||
|
case calibrationStaleDays = "calibration_stale_days"
|
||||||
|
case defaultInstallLocation = "default_install_location"
|
||||||
|
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
|
||||||
|
case openColorPanelAfterInstall = "open_color_panel_after_install"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One element of `custom_presets`: typed first, legacy M1 second.
|
||||||
|
private enum AnyPreset: Decodable {
|
||||||
|
case typed(ProfilingPreset)
|
||||||
|
case legacy(CustomPreset)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
if let p = try? ProfilingPreset(from: decoder) {
|
||||||
|
self = .typed(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self = .legacy(try CustomPreset(from: decoder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let d = AppSettings.default
|
||||||
|
argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir
|
||||||
|
defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument
|
||||||
|
logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel
|
||||||
|
deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax
|
||||||
|
deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax
|
||||||
|
enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds
|
||||||
|
calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays
|
||||||
|
defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation
|
||||||
|
askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile
|
||||||
|
openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall
|
||||||
|
|
||||||
|
// Per-element decode: typed presets win; a legacy M1 shape
|
||||||
|
// ({"name","values"}) migrates; unconvertible entries are
|
||||||
|
// skipped so one bad record never drops the array.
|
||||||
|
let elements = (try? c.decodeIfPresent(
|
||||||
|
[FailableDecodable<AnyPreset>].self, forKey: .customPresets
|
||||||
|
)) ?? nil
|
||||||
|
var migrated: [ProfilingPreset] = []
|
||||||
|
for (index, element) in (elements ?? []).enumerated() {
|
||||||
|
switch element.value {
|
||||||
|
case .typed(let preset):
|
||||||
|
migrated.append(preset)
|
||||||
|
case .legacy(let legacy):
|
||||||
|
if let converted = ProfilingPreset(migrating: legacy, index: index) {
|
||||||
|
migrated.append(converted)
|
||||||
|
} else {
|
||||||
|
AppLogger(category: "settings").warn(
|
||||||
|
"Skipped unmigratable legacy preset: \(legacy.name)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .none:
|
||||||
|
AppLogger(category: "settings").warn(
|
||||||
|
"Skipped malformed preset entry at index \(index)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customPresets = migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-facing validation. Strings are part of the contract (issue #5).
|
||||||
|
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
|
||||||
|
public static let errorThresholdOrder =
|
||||||
|
"Good ΔE threshold must be strictly less than the warning threshold."
|
||||||
|
|
||||||
|
/// All validation errors, in declaration order. Empty = valid.
|
||||||
|
public func validate() -> [String] {
|
||||||
|
var errors: [String] = []
|
||||||
|
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
|
||||||
|
errors.append(Self.errorNegativeDeltaE)
|
||||||
|
}
|
||||||
|
if deltaEGoodMax >= deltaEWarningMax {
|
||||||
|
errors.append(Self.errorThresholdOrder)
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isValid: Bool { validate().isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProfilingPreset {
|
||||||
|
|
||||||
|
/// Migrates a legacy M1 `CustomPreset` (`name` + string values) to
|
||||||
|
/// the typed schema. Known keys are coerced; anything else is
|
||||||
|
/// ignored. Returns `nil` only when the name is unusable — a
|
||||||
|
/// deterministic `custom-{index}-{slug}` id is always produced.
|
||||||
|
init?(migrating legacy: CustomPreset, index: Int) {
|
||||||
|
let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let v = legacy.values
|
||||||
|
func int(_ key: String) -> Int? {
|
||||||
|
v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
}
|
||||||
|
func double(_ key: String) -> Double? {
|
||||||
|
v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
}
|
||||||
|
func bool(_ key: String) -> Bool? {
|
||||||
|
v[key].flatMap { s in
|
||||||
|
switch s.trimmingCharacters(in: .whitespaces).lowercased() {
|
||||||
|
case "true", "1", "yes": return true
|
||||||
|
case "false", "0", "no": return false
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func string(_ key: String) -> String? {
|
||||||
|
v[key].map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = trimmedName.lowercased()
|
||||||
|
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
|
||||||
|
.reduce(into: "") { $0.append($1) }
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: "custom-\(index)-\(slug)",
|
||||||
|
name: trimmedName,
|
||||||
|
description: string("description") ?? "",
|
||||||
|
colourSpace: string("colour_space")?.lowercased() ?? "rgb",
|
||||||
|
patchCount: int("patch_count") ?? 800,
|
||||||
|
whitePatches: int("white_patches") ?? 4,
|
||||||
|
blackPatches: int("black_patches") ?? 4,
|
||||||
|
greySteps: int("grey_steps"),
|
||||||
|
singleChannelSteps: int("single_channel_steps"),
|
||||||
|
neutralSteps: int("neutral_steps"),
|
||||||
|
neutralConcentration: double("neutral_concentration"),
|
||||||
|
preconditioningProfile: string("preconditioning_profile"),
|
||||||
|
ofpsHighQuality: bool("ofps_high_quality"),
|
||||||
|
ofpsAdaptation: double("ofps_adaptation"),
|
||||||
|
fullSpreadAlgorithm: string("full_spread_algorithm"),
|
||||||
|
totalInkLimit: int("total_ink_limit"),
|
||||||
|
darkEmphasis: double("dark_emphasis"),
|
||||||
|
devicePower: double("device_power"),
|
||||||
|
instrument: string("instrument") ?? "i1",
|
||||||
|
pageSize: string("page_size") ?? "A4",
|
||||||
|
bitDepth: int("bit_depth") ?? 8,
|
||||||
|
dpi: int("dpi") ?? 300,
|
||||||
|
randomSeed: int("random_seed"),
|
||||||
|
noRandomize: bool("no_randomize"),
|
||||||
|
calibrationFile: string("calibration_file"),
|
||||||
|
applyCalibration: bool("apply_calibration"),
|
||||||
|
colprofAlgorithm: string("colprof_algorithm"),
|
||||||
|
colprofQuality: string("colprof_quality"),
|
||||||
|
colprofIntent: string("colprof_intent"),
|
||||||
|
colprofFwa: string("colprof_fwa"),
|
||||||
|
colprofIlluminant: string("colprof_illuminant"),
|
||||||
|
colprofObserver: string("colprof_observer"),
|
||||||
|
colprofInputViewingCond: string("colprof_input_viewing_cond"),
|
||||||
|
colprofOutputViewingCond: string("colprof_output_viewing_cond")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Built-in presets shipped with the app (docs/22 §Built-in presets).
|
||||||
|
/// All four: instrument `i1`, FWA `D50`, random seed `1`,
|
||||||
|
/// `no_randomize == false`, colprof algorithm `l`.
|
||||||
|
///
|
||||||
|
/// Built-ins cannot be deleted; custom presets overlay by `id`.
|
||||||
|
public enum PresetCatalog {
|
||||||
|
|
||||||
|
/// `preset-std-rgb` — Standard RGB Photo (800 patches).
|
||||||
|
public static let standardRGB = ProfilingPreset(
|
||||||
|
id: "preset-std-rgb",
|
||||||
|
name: "Standard RGB Photo (800 patches)",
|
||||||
|
description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 800,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A4",
|
||||||
|
bitDepth: 8,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "m",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-hq-cmyk` — High-Gamut CMYK Proofing (1500 patches).
|
||||||
|
public static let highQualityCMYK = ProfilingPreset(
|
||||||
|
id: "preset-hq-cmyk",
|
||||||
|
name: "High-Gamut CMYK Proofing (1500 patches)",
|
||||||
|
description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.",
|
||||||
|
colourSpace: "cmyk",
|
||||||
|
patchCount: 1500,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 8,
|
||||||
|
totalInkLimit: 320,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A3",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "h",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-draft-rgb` — Fast RGB Draft (400 patches, **150 dpi**).
|
||||||
|
public static let draftRGB = ProfilingPreset(
|
||||||
|
id: "preset-draft-rgb",
|
||||||
|
name: "Fast RGB Draft (400 patches)",
|
||||||
|
description: "Quick sanity check — 400 patches on A4 at 150 dpi.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 400,
|
||||||
|
whitePatches: 4,
|
||||||
|
blackPatches: 4,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A4",
|
||||||
|
bitDepth: 8,
|
||||||
|
dpi: 150,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "l",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
/// `preset-ultra-rgb` — Ultra Precision RGB (2500 patches, `-G`).
|
||||||
|
public static let ultraRGB = ProfilingPreset(
|
||||||
|
id: "preset-ultra-rgb",
|
||||||
|
name: "Ultra Precision RGB (2500 patches)",
|
||||||
|
description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.",
|
||||||
|
colourSpace: "rgb",
|
||||||
|
patchCount: 2500,
|
||||||
|
whitePatches: 6,
|
||||||
|
blackPatches: 6,
|
||||||
|
ofpsHighQuality: true,
|
||||||
|
instrument: "i1",
|
||||||
|
pageSize: "A3",
|
||||||
|
bitDepth: 16,
|
||||||
|
dpi: 300,
|
||||||
|
randomSeed: 1,
|
||||||
|
noRandomize: false,
|
||||||
|
colprofAlgorithm: "l",
|
||||||
|
colprofQuality: "u",
|
||||||
|
colprofFwa: "D50"
|
||||||
|
)
|
||||||
|
|
||||||
|
public static let builtIns: [ProfilingPreset] = [
|
||||||
|
standardRGB, highQualityCMYK, draftRGB, ultraRGB,
|
||||||
|
]
|
||||||
|
|
||||||
|
public static let builtInIDs: Set<String> = Set(builtIns.map(\.id))
|
||||||
|
|
||||||
|
public static func isBuiltIn(_ id: String) -> Bool {
|
||||||
|
builtInIDs.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-ins plus custom presets, with custom entries overlaying by
|
||||||
|
/// `id` (a custom preset with a built-in id replaces that entry in
|
||||||
|
/// place — the built-in is still not deletable).
|
||||||
|
public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] {
|
||||||
|
var result = builtIns
|
||||||
|
var seen = builtInIDs
|
||||||
|
for custom in custom {
|
||||||
|
if let idx = result.firstIndex(where: { $0.id == custom.id }) {
|
||||||
|
result[idx] = custom
|
||||||
|
} else if !seen.contains(custom.id) {
|
||||||
|
result.append(custom)
|
||||||
|
seen.insert(custom.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CRUD + import/export for profiling presets on top of `SettingsStore`
|
||||||
|
/// (docs/22 §Built-in presets, issue #11).
|
||||||
|
///
|
||||||
|
/// - `all()` = built-ins overlaid by custom presets (by `id`).
|
||||||
|
/// - Built-ins are never written to `settings.json` and cannot be
|
||||||
|
/// deleted or overwritten by `saveCustom` (a custom id that collides
|
||||||
|
/// with a built-in still overlays at read time, per spec).
|
||||||
|
/// - Import/export is single-preset JSON with schema validation.
|
||||||
|
/// - Imported names/descriptions are untrusted: callers must render
|
||||||
|
/// them with `Text`, never HTML (#114).
|
||||||
|
public final class PresetStore: Sendable {
|
||||||
|
|
||||||
|
public let settingsStore: SettingsStore
|
||||||
|
|
||||||
|
public init(settingsStore: SettingsStore = SettingsStore()) {
|
||||||
|
self.settingsStore = settingsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All presets: built-ins overlaid by customs, catalog order.
|
||||||
|
public func all() -> [ProfilingPreset] {
|
||||||
|
PresetCatalog.all(custom: settingsStore.load().customPresets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom presets only, as persisted.
|
||||||
|
public func customs() -> [ProfilingPreset] {
|
||||||
|
settingsStore.load().customPresets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace a custom preset (matched by `id`). Throws
|
||||||
|
/// `PresetStoreError.builtIn` when the id belongs to a built-in —
|
||||||
|
/// built-ins are immutable. Validates before persisting.
|
||||||
|
public func saveCustom(_ preset: ProfilingPreset) throws {
|
||||||
|
let validated = try preset.validated()
|
||||||
|
guard !PresetCatalog.isBuiltIn(validated.id) else {
|
||||||
|
throw PresetStoreError.builtInImmutable(validated.id)
|
||||||
|
}
|
||||||
|
var settings = settingsStore.load()
|
||||||
|
if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) {
|
||||||
|
settings.customPresets[idx] = validated
|
||||||
|
} else {
|
||||||
|
settings.customPresets.append(validated)
|
||||||
|
}
|
||||||
|
try settingsStore.save(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a custom preset by id. Returns false when the id is a
|
||||||
|
/// built-in (undeletable) or no custom preset with that id exists.
|
||||||
|
@discardableResult
|
||||||
|
public func deleteCustom(id: String) throws -> Bool {
|
||||||
|
guard !PresetCatalog.isBuiltIn(id) else { return false }
|
||||||
|
var settings = settingsStore.load()
|
||||||
|
let before = settings.customPresets.count
|
||||||
|
settings.customPresets.removeAll { $0.id == id }
|
||||||
|
guard settings.customPresets.count != before else { return false }
|
||||||
|
try settingsStore.save(settings)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-preset pretty JSON export.
|
||||||
|
public func export(_ preset: ProfilingPreset) throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return try encoder.encode(preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses + validates a preset from JSON. The preset is assigned a
|
||||||
|
/// fresh custom id when its id is empty or collides with a built-in.
|
||||||
|
/// Does **not** persist — call `saveCustom` to keep it.
|
||||||
|
public func `import`(_ data: Data) throws -> ProfilingPreset {
|
||||||
|
let decoded: ProfilingPreset
|
||||||
|
do {
|
||||||
|
decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw PresetStoreError.invalidJSON(error.localizedDescription)
|
||||||
|
}
|
||||||
|
var preset = try decoded.validated()
|
||||||
|
if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) {
|
||||||
|
preset.id = "custom-\(UUID().uuidString.lowercased())"
|
||||||
|
}
|
||||||
|
return preset
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PresetStoreError: LocalizedError, Equatable {
|
||||||
|
case builtInImmutable(String)
|
||||||
|
case invalidJSON(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .builtInImmutable(let id):
|
||||||
|
return "Built-in preset \"\(id)\" cannot be modified or deleted."
|
||||||
|
case .invalidJSON(let reason):
|
||||||
|
return "Not a valid preset file: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A profiling preset: a complete snapshot of the Stage 1/2 form plus
|
||||||
|
/// the Stage 4 fields that are stored now and applied in issue 23
|
||||||
|
/// (docs/22 §ProfilingPreset).
|
||||||
|
///
|
||||||
|
/// snake_case keys match the v1 JSON schema so import/export stays
|
||||||
|
/// compatible. Identity + Stage 1/2 core fields are required; every
|
||||||
|
/// other field is optional-defaulted. Unknown keys are ignored on
|
||||||
|
/// decode; missing required fields fail.
|
||||||
|
public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable {
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
public var description: String
|
||||||
|
|
||||||
|
// Stage 1 (required core)
|
||||||
|
public var colourSpace: String // "rgb" | "cmyk"
|
||||||
|
public var patchCount: Int
|
||||||
|
public var whitePatches: Int
|
||||||
|
public var blackPatches: Int
|
||||||
|
|
||||||
|
// Stage 1 advanced (optional)
|
||||||
|
public var greySteps: Int?
|
||||||
|
public var singleChannelSteps: Int?
|
||||||
|
public var neutralSteps: Int?
|
||||||
|
public var neutralConcentration: Double?
|
||||||
|
public var preconditioningProfile: String?
|
||||||
|
public var ofpsHighQuality: Bool?
|
||||||
|
public var ofpsAdaptation: Double?
|
||||||
|
/// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I".
|
||||||
|
public var fullSpreadAlgorithm: String?
|
||||||
|
public var totalInkLimit: Int?
|
||||||
|
public var darkEmphasis: Double?
|
||||||
|
public var devicePower: Double?
|
||||||
|
|
||||||
|
// Stage 2 (required core)
|
||||||
|
public var instrument: String
|
||||||
|
public var pageSize: String
|
||||||
|
public var bitDepth: Int
|
||||||
|
public var dpi: Int
|
||||||
|
public var randomSeed: Int?
|
||||||
|
public var noRandomize: Bool?
|
||||||
|
|
||||||
|
// Stage 0 / 2 calibration
|
||||||
|
public var calibrationFile: String?
|
||||||
|
public var applyCalibration: Bool?
|
||||||
|
|
||||||
|
// Stage 4 (stored now, applied by issue 23)
|
||||||
|
public var colprofAlgorithm: String?
|
||||||
|
public var colprofQuality: String?
|
||||||
|
public var colprofIntent: String?
|
||||||
|
public var colprofFwa: String?
|
||||||
|
public var colprofIlluminant: String?
|
||||||
|
public var colprofObserver: String?
|
||||||
|
public var colprofInputViewingCond: String?
|
||||||
|
public var colprofOutputViewingCond: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: String = "",
|
||||||
|
colourSpace: String = "rgb",
|
||||||
|
patchCount: Int = 800,
|
||||||
|
whitePatches: Int = 4,
|
||||||
|
blackPatches: Int = 4,
|
||||||
|
greySteps: Int? = nil,
|
||||||
|
singleChannelSteps: Int? = nil,
|
||||||
|
neutralSteps: Int? = nil,
|
||||||
|
neutralConcentration: Double? = nil,
|
||||||
|
preconditioningProfile: String? = nil,
|
||||||
|
ofpsHighQuality: Bool? = nil,
|
||||||
|
ofpsAdaptation: Double? = nil,
|
||||||
|
fullSpreadAlgorithm: String? = nil,
|
||||||
|
totalInkLimit: Int? = nil,
|
||||||
|
darkEmphasis: Double? = nil,
|
||||||
|
devicePower: Double? = nil,
|
||||||
|
instrument: String = "i1",
|
||||||
|
pageSize: String = "A4",
|
||||||
|
bitDepth: Int = 8,
|
||||||
|
dpi: Int = 300,
|
||||||
|
randomSeed: Int? = 1,
|
||||||
|
noRandomize: Bool? = false,
|
||||||
|
calibrationFile: String? = nil,
|
||||||
|
applyCalibration: Bool? = nil,
|
||||||
|
colprofAlgorithm: String? = nil,
|
||||||
|
colprofQuality: String? = nil,
|
||||||
|
colprofIntent: String? = nil,
|
||||||
|
colprofFwa: String? = nil,
|
||||||
|
colprofIlluminant: String? = nil,
|
||||||
|
colprofObserver: String? = nil,
|
||||||
|
colprofInputViewingCond: String? = nil,
|
||||||
|
colprofOutputViewingCond: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.colourSpace = colourSpace
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.whitePatches = whitePatches
|
||||||
|
self.blackPatches = blackPatches
|
||||||
|
self.greySteps = greySteps
|
||||||
|
self.singleChannelSteps = singleChannelSteps
|
||||||
|
self.neutralSteps = neutralSteps
|
||||||
|
self.neutralConcentration = neutralConcentration
|
||||||
|
self.preconditioningProfile = preconditioningProfile
|
||||||
|
self.ofpsHighQuality = ofpsHighQuality
|
||||||
|
self.ofpsAdaptation = ofpsAdaptation
|
||||||
|
self.fullSpreadAlgorithm = fullSpreadAlgorithm
|
||||||
|
self.totalInkLimit = totalInkLimit
|
||||||
|
self.darkEmphasis = darkEmphasis
|
||||||
|
self.devicePower = devicePower
|
||||||
|
self.instrument = instrument
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.bitDepth = bitDepth
|
||||||
|
self.dpi = dpi
|
||||||
|
self.randomSeed = randomSeed
|
||||||
|
self.noRandomize = noRandomize
|
||||||
|
self.calibrationFile = calibrationFile
|
||||||
|
self.applyCalibration = applyCalibration
|
||||||
|
self.colprofAlgorithm = colprofAlgorithm
|
||||||
|
self.colprofQuality = colprofQuality
|
||||||
|
self.colprofIntent = colprofIntent
|
||||||
|
self.colprofFwa = colprofFwa
|
||||||
|
self.colprofIlluminant = colprofIlluminant
|
||||||
|
self.colprofObserver = colprofObserver
|
||||||
|
self.colprofInputViewingCond = colprofInputViewingCond
|
||||||
|
self.colprofOutputViewingCond = colprofOutputViewingCond
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description
|
||||||
|
case colourSpace = "colour_space"
|
||||||
|
case patchCount = "patch_count"
|
||||||
|
case whitePatches = "white_patches"
|
||||||
|
case blackPatches = "black_patches"
|
||||||
|
case greySteps = "grey_steps"
|
||||||
|
case singleChannelSteps = "single_channel_steps"
|
||||||
|
case neutralSteps = "neutral_steps"
|
||||||
|
case neutralConcentration = "neutral_concentration"
|
||||||
|
case preconditioningProfile = "preconditioning_profile"
|
||||||
|
case ofpsHighQuality = "ofps_high_quality"
|
||||||
|
case ofpsAdaptation = "ofps_adaptation"
|
||||||
|
case fullSpreadAlgorithm = "full_spread_algorithm"
|
||||||
|
case totalInkLimit = "total_ink_limit"
|
||||||
|
case darkEmphasis = "dark_emphasis"
|
||||||
|
case devicePower = "device_power"
|
||||||
|
case instrument
|
||||||
|
case pageSize = "page_size"
|
||||||
|
case bitDepth = "bit_depth"
|
||||||
|
case dpi
|
||||||
|
case randomSeed = "random_seed"
|
||||||
|
case noRandomize = "no_randomize"
|
||||||
|
case calibrationFile = "calibration_file"
|
||||||
|
case applyCalibration = "apply_calibration"
|
||||||
|
case colprofAlgorithm = "colprof_algorithm"
|
||||||
|
case colprofQuality = "colprof_quality"
|
||||||
|
case colprofIntent = "colprof_intent"
|
||||||
|
case colprofFwa = "colprof_fwa"
|
||||||
|
case colprofIlluminant = "colprof_illuminant"
|
||||||
|
case colprofObserver = "colprof_observer"
|
||||||
|
case colprofInputViewingCond = "colprof_input_viewing_cond"
|
||||||
|
case colprofOutputViewingCond = "colprof_output_viewing_cond"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict decode: required identity + Stage 1/2 core fields must be
|
||||||
|
/// present; optionals default to nil. Unknown keys are ignored.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
|
||||||
|
colourSpace = try c.decode(String.self, forKey: .colourSpace)
|
||||||
|
patchCount = try c.decode(Int.self, forKey: .patchCount)
|
||||||
|
whitePatches = try c.decode(Int.self, forKey: .whitePatches)
|
||||||
|
blackPatches = try c.decode(Int.self, forKey: .blackPatches)
|
||||||
|
greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps)
|
||||||
|
singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps)
|
||||||
|
neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps)
|
||||||
|
neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration)
|
||||||
|
preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile)
|
||||||
|
ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality)
|
||||||
|
ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation)
|
||||||
|
fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm)
|
||||||
|
totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit)
|
||||||
|
darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis)
|
||||||
|
devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower)
|
||||||
|
instrument = try c.decode(String.self, forKey: .instrument)
|
||||||
|
pageSize = try c.decode(String.self, forKey: .pageSize)
|
||||||
|
bitDepth = try c.decode(Int.self, forKey: .bitDepth)
|
||||||
|
dpi = try c.decode(Int.self, forKey: .dpi)
|
||||||
|
randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed)
|
||||||
|
noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize)
|
||||||
|
calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile)
|
||||||
|
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration)
|
||||||
|
colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm)
|
||||||
|
colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality)
|
||||||
|
colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent)
|
||||||
|
colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa)
|
||||||
|
colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant)
|
||||||
|
colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver)
|
||||||
|
colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond)
|
||||||
|
colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation (import path)
|
||||||
|
|
||||||
|
public enum ValidationError: LocalizedError, Equatable {
|
||||||
|
case emptyID
|
||||||
|
case emptyName
|
||||||
|
case invalidColourSpace(String)
|
||||||
|
case invalidPatchCount(Int)
|
||||||
|
case invalidBitDepth(Int)
|
||||||
|
case invalidDPI(Int)
|
||||||
|
case emptyPageSize
|
||||||
|
case emptyInstrument
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyID: return "Preset is missing an id."
|
||||||
|
case .emptyName: return "Preset is missing a name."
|
||||||
|
case .invalidColourSpace(let v):
|
||||||
|
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
|
||||||
|
case .invalidPatchCount(let v):
|
||||||
|
return "patch_count must be positive, got \(v)."
|
||||||
|
case .invalidBitDepth(let v):
|
||||||
|
return "bit_depth must be 8 or 16, got \(v)."
|
||||||
|
case .invalidDPI(let v):
|
||||||
|
return "dpi must be between 72 and 600, got \(v)."
|
||||||
|
case .emptyPageSize: return "page_size is empty."
|
||||||
|
case .emptyInstrument: return "instrument is empty."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the required fields for import / catalog use.
|
||||||
|
/// `colourSpace` is normalized to lowercase before comparison.
|
||||||
|
@discardableResult
|
||||||
|
public func validated() throws -> ProfilingPreset {
|
||||||
|
var p = self
|
||||||
|
p.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
p.colourSpace = colourSpace.lowercased()
|
||||||
|
guard !p.id.isEmpty else { throw ValidationError.emptyID }
|
||||||
|
guard !p.name.isEmpty else { throw ValidationError.emptyName }
|
||||||
|
guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else {
|
||||||
|
throw ValidationError.invalidColourSpace(colourSpace)
|
||||||
|
}
|
||||||
|
guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) }
|
||||||
|
guard p.bitDepth == 8 || p.bitDepth == 16 else {
|
||||||
|
throw ValidationError.invalidBitDepth(bitDepth)
|
||||||
|
}
|
||||||
|
guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) }
|
||||||
|
guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw ValidationError.emptyPageSize
|
||||||
|
}
|
||||||
|
guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||||
|
throw ValidationError.emptyInstrument
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-element non-throwing decode wrapper — one malformed preset entry
|
||||||
|
/// must not drop the whole `custom_presets` array during migration.
|
||||||
|
struct FailableDecodable<T: Decodable>: Decodable {
|
||||||
|
let value: T?
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
value = try? T(from: decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persists `AppSettings` to
|
||||||
|
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
|
||||||
|
/// (issue #5 — the v1 path is never read).
|
||||||
|
///
|
||||||
|
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
|
||||||
|
/// back to defaults. Saving posts `settingsDidChange` so #20 can
|
||||||
|
/// reclassify swatches.
|
||||||
|
public final class SettingsStore: Sendable {
|
||||||
|
|
||||||
|
/// Posted on `NotificationCenter.default` after every successful save.
|
||||||
|
public static let settingsDidChange =
|
||||||
|
Notification.Name("com.gronod.iccery2.settingsDidChange")
|
||||||
|
|
||||||
|
public let fileURL: URL
|
||||||
|
|
||||||
|
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
|
||||||
|
self.fileURL = fileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() -> AppSettings {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
|
||||||
|
else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates before persisting — throws `SettingsError` listing
|
||||||
|
/// every violation; nothing is written on failure.
|
||||||
|
public func save(_ settings: AppSettings) throws {
|
||||||
|
let errors = settings.validate()
|
||||||
|
guard errors.isEmpty else {
|
||||||
|
throw SettingsError.validationFailed(errors)
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
|
||||||
|
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SettingsError: Error, Equatable {
|
||||||
|
case validationFailed([String])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
|
||||||
|
///
|
||||||
|
/// Navigation is *disk*, not buttons: a stage unlocks only when its
|
||||||
|
/// predecessor artefacts exist. Forward moves are gated; backward is
|
||||||
|
/// always allowed. Gating is re-evaluated on window focus and on stage
|
||||||
|
/// entry (#151 — files can disappear in Finder).
|
||||||
|
public enum WizardGating {
|
||||||
|
|
||||||
|
/// Whether `stage` is reachable given the probed artefacts.
|
||||||
|
///
|
||||||
|
/// - Stage 0 (calibrate): always — it is out-of-band, not gated.
|
||||||
|
/// - Stage 1: always.
|
||||||
|
/// - Stage 2: `.ti1` exists.
|
||||||
|
/// - Stage 3: `.ti1` **and** `.ti2`.
|
||||||
|
/// - Stage 4: `.ti3` exists (accepted measurement only — a `.ti2`
|
||||||
|
/// alone never unlocks it; #109/#110).
|
||||||
|
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
|
||||||
|
public static func isUnlocked(
|
||||||
|
_ stage: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
switch stage {
|
||||||
|
case .calibrate: return true
|
||||||
|
case .generate: return true
|
||||||
|
case .layOutPrint: return artefacts.stage1Complete
|
||||||
|
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
|
||||||
|
case .buildProfile: return artefacts.stage3Complete
|
||||||
|
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `go(to:)` may proceed. Backward moves and the current
|
||||||
|
/// stage are always allowed; forward moves must be unlocked.
|
||||||
|
public static func canNavigate(
|
||||||
|
to target: WizardStage,
|
||||||
|
from current: WizardStage,
|
||||||
|
artefacts: StageArtefacts
|
||||||
|
) -> Bool {
|
||||||
|
if target == current { return true }
|
||||||
|
if target == .calibrate || current == .calibrate {
|
||||||
|
// Stage 0 is a side-trip, not stepper navigation.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if target.rawValue < current.rawValue { return true }
|
||||||
|
return isUnlocked(target, artefacts: artefacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deepest unlocked stepper stage — used when revalidation
|
||||||
|
/// locks the current stage (#151).
|
||||||
|
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
|
||||||
|
for stage in WizardStage.stepperStages.reversed()
|
||||||
|
where isUnlocked(stage, artefacts: artefacts) {
|
||||||
|
return stage
|
||||||
|
}
|
||||||
|
return .generate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,166 +1 @@
|
|||||||
# ICCery
|
# iccery-v2-mac
|
||||||
|
|
||||||
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
|
|
||||||
|
|
||||||
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Product | ICCery v2 for macOS |
|
|
||||||
| Bundle | `com.gronod.iccery2` |
|
|
||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
|
||||||
| Default branch | `develop` (M1–M5) |
|
|
||||||
| In flight | `milestone/m6-gamut-stage0-cgats-release` (M6) |
|
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
On `develop` the wizard is artefact-gated:
|
|
||||||
|
|
||||||
1. **Stage 1** — `targen` → `.ti1`
|
|
||||||
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
|
|
||||||
3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
|
|
||||||
4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
|
|
||||||
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
|
|
||||||
|
|
||||||
Settings, logging, resume-from-artefacts, and ColorSync suppression for unmanaged jobs ship with M1–M5.
|
|
||||||
|
|
||||||
**M6 (milestone branch, not on `develop` yet):** Stage 0 printer calibration, CGATS dataset import, SceneKit gamut viewer, About/help chrome, signed `.dmg`.
|
|
||||||
|
|
||||||
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- macOS 14+
|
|
||||||
- Xcode 15+ with the macOS 14 SDK (Swift 5.10)
|
|
||||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
|
|
||||||
- Network once, to fetch Argyll sidecars
|
|
||||||
|
|
||||||
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.i3omb.com/gronod/iccery-v2-mac.git
|
|
||||||
cd iccery-v2-mac
|
|
||||||
git checkout develop
|
|
||||||
|
|
||||||
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
|
|
||||||
make test # xcodegen + xcodebuild build test
|
|
||||||
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent without Make:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xcodegen generate
|
|
||||||
xcodebuild test -scheme ICCery \
|
|
||||||
-destination 'platform=macOS' \
|
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# optional
|
|
||||||
export ARGYLL_SERVER_URL=https://git.i3omb.com
|
|
||||||
export ARGYLL_REPO=gronod/argyllcms
|
|
||||||
export ARGYLL_RELEASE_TAG=… # default: latest
|
|
||||||
export GITEA_TOKEN=… # private releases
|
|
||||||
```
|
|
||||||
|
|
||||||
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
|
|
||||||
|
|
||||||
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
Sources/ICCery/ SwiftUI + AppKit shell, stage views, workflow VMs
|
|
||||||
Packages/ICCeryCore/ wizard state, ProcessManager, argv builders,
|
|
||||||
settings, CGATS, ΔE₀₀ — no NSPrintPanel
|
|
||||||
Resources/ assets; Argyll reference files (not the tools)
|
|
||||||
Vendor/Argyll/ fetched sidecars (gitignored)
|
|
||||||
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
|
|
||||||
Tests/ICCeryUITests/ fixture / mock-binary UI tests
|
|
||||||
scripts/fetch-argyll.sh
|
|
||||||
docs/ functional spec + v2 ticket plan
|
|
||||||
```
|
|
||||||
|
|
||||||
`ICCeryPrintKit` (issue #16, Quartz / TargetPrint) is v2.1 and is not in this tree.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
|
|
||||||
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, 500 ms, kill; `killAll` on terminate.
|
|
||||||
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
|
|
||||||
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
|
|
||||||
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
|
|
||||||
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# full suite (universal)
|
|
||||||
xcodebuild test -scheme ICCery \
|
|
||||||
-destination 'platform=macOS' \
|
|
||||||
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
|
|
||||||
|
|
||||||
# examples
|
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|
||||||
-only-testing:ICCeryCoreTests/ChartreadClassifierTests
|
|
||||||
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
|
|
||||||
-only-testing:ICCeryUITests/Milestone5UITests
|
|
||||||
```
|
|
||||||
|
|
||||||
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
|
|
||||||
|
|
||||||
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
|
|
||||||
|
|
||||||
## Instruments
|
|
||||||
|
|
||||||
Detected via bundled `instlist`:
|
|
||||||
|
|
||||||
- i1 Pro / i1 Pro 2 (`i1`)
|
|
||||||
- ColorMunki (`CM`)
|
|
||||||
- SpyderPrint (`p3`)
|
|
||||||
- SpectroScan (`SS`)
|
|
||||||
- DTP20 / 22 / 41 / 51
|
|
||||||
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
Normative spec is [`docs/`](docs/README.md). Implementation order:
|
|
||||||
|
|
||||||
| Doc | Topic |
|
|
||||||
|---|---|
|
|
||||||
| [`docs/01-overview.md`](docs/01-overview.md) | Product and wizard |
|
|
||||||
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
|
|
||||||
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
|
|
||||||
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
|
|
||||||
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
|
|
||||||
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
|
|
||||||
|
|
||||||
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
```
|
|
||||||
develop
|
|
||||||
└── milestone/mN-<slug> # integration only
|
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
|
||||||
```
|
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Do not open umbrella “bugfix” branches that mix tickets.
|
|
||||||
|
|
||||||
## Licence
|
|
||||||
|
|
||||||
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
|
|
||||||
|
|
||||||
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
|
|
||||||
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
|
|
||||||
-
|
|
||||||
|
|||||||
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,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// About dialog for ICCery (issue #31, docs/21 §Modals).
|
||||||
|
struct AboutView: View {
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
private let info = ArtefactFiles.appInfo()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image("ICCery-logo")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 64)
|
||||||
|
|
||||||
|
Text("ICCery")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Version:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.version)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutVersion")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Build:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.build)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Build date:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(info.buildDate)
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
.accessibilityIdentifier("aboutBuildDate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
Text("Native macOS printer profiling workstation.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button("Close") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.accessibilityIdentifier("closeAboutBtn")
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 360)
|
||||||
|
.background(Theme.panel)
|
||||||
|
.accessibilityIdentifier("aboutDialog")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// App dependency container (docs/02). Production builds resolve the
|
||||||
|
/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI
|
||||||
|
/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and
|
||||||
|
/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the
|
||||||
|
/// developer's settings, wizard state, or real Argyll install.
|
||||||
|
struct AppEnvironment: Sendable {
|
||||||
|
let stateStore: WizardStateStore
|
||||||
|
let settingsStore: SettingsStore
|
||||||
|
let presetStore: PresetStore
|
||||||
|
let runner: ArgyllRunner
|
||||||
|
let cupsService: CupsService
|
||||||
|
let historyStore: VerificationHistoryStore
|
||||||
|
|
||||||
|
static func live(
|
||||||
|
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||||
|
) -> AppEnvironment {
|
||||||
|
let settingsStore = SettingsStore()
|
||||||
|
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||||
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||||
|
#if DEBUG
|
||||||
|
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||||
|
overrideDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
|
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||||
|
cupsDir = URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return AppEnvironment(
|
||||||
|
stateStore: WizardStateStore(),
|
||||||
|
settingsStore: settingsStore,
|
||||||
|
presetStore: PresetStore(settingsStore: settingsStore),
|
||||||
|
runner: ArgyllRunner(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
||||||
|
),
|
||||||
|
cupsService: CupsService(
|
||||||
|
processManager: .shared,
|
||||||
|
binaryDir: cupsDir),
|
||||||
|
historyStore: VerificationHistoryStore()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow
|
||||||
|
/// honours these env-provided paths instead of presenting modal panels
|
||||||
|
/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are
|
||||||
|
/// compiled out of release builds.
|
||||||
|
enum UITestHooks {
|
||||||
|
private static var env: [String: String] {
|
||||||
|
ProcessInfo.processInfo.environment
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isEnabled: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return env["ICCERY_UI_TESTING"] == "1"
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `select_target_file` result (Stage 1 save picker).
|
||||||
|
static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") }
|
||||||
|
/// `select_existing_target` result (`.ti1`/`.ti2` resume).
|
||||||
|
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
|
||||||
|
/// `select_directory` result (working-directory browse).
|
||||||
|
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
|
||||||
|
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
|
||||||
|
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
|
||||||
|
/// Preset import file.
|
||||||
|
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
|
||||||
|
/// Preset export destination.
|
||||||
|
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||||
|
|
||||||
|
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||||
|
|
||||||
|
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
||||||
|
/// `CupsService.binaryDir` under UI tests.
|
||||||
|
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||||
|
|
||||||
|
/// Path the mock `lp` script appends its argv to, for assertions.
|
||||||
|
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
||||||
|
|
||||||
|
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||||
|
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||||
|
/// fall through to the real modal.
|
||||||
|
static var printPanelStubbed: Bool { isEnabled }
|
||||||
|
|
||||||
|
/// Canned `NSPrintPanel` outcome — XCUITest cannot drive the
|
||||||
|
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
|
||||||
|
/// - `cancel` (or unset while testing) → user cancelled → `nil`
|
||||||
|
/// - `ok` → `PrintPropertiesResult` with
|
||||||
|
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
||||||
|
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
||||||
|
/// queue the panel was opened for).
|
||||||
|
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
|
||||||
|
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
||||||
|
case "ok":
|
||||||
|
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
return PrintPropertiesResult(
|
||||||
|
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
} ?? queue,
|
||||||
|
options: PrintOptions(
|
||||||
|
mediaType: options.flatMap {
|
||||||
|
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||||
|
},
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: options))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func url(_ key: String) -> URL? {
|
||||||
|
guard let raw = env[key], !raw.isEmpty else { return nil }
|
||||||
|
return URL(fileURLWithPath: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Minimal line chart for drift history without depending on the
|
||||||
|
/// `Charts` framework link. Renders avg/max series with shaded quality
|
||||||
|
/// bands.
|
||||||
|
struct DriftChartView: View {
|
||||||
|
let records: [VerificationRecord]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = geometry.size.width
|
||||||
|
let height = geometry.size.height
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if let (_, _, _, maxV) = scales(in: height) {
|
||||||
|
// Quality bands — bottom (red, > 3.5) drawn first, then
|
||||||
|
// orange, yellow, green so the upper-most bands overlay.
|
||||||
|
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
|
||||||
|
// Average ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.avgDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.blue, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftAvgSeries")
|
||||||
|
|
||||||
|
// Max ΔE series
|
||||||
|
Path { path in
|
||||||
|
for (index, record) in records.enumerated() {
|
||||||
|
let pt = point(
|
||||||
|
for: record,
|
||||||
|
minTime: minT,
|
||||||
|
maxTime: maxT,
|
||||||
|
minValue: minV,
|
||||||
|
maxValue: maxV,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
keyPath: \.maxDE
|
||||||
|
)
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: pt)
|
||||||
|
} else {
|
||||||
|
path.addLine(to: pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(Color.orange, lineWidth: 2)
|
||||||
|
.accessibilityIdentifier("driftMaxSeries")
|
||||||
|
} else {
|
||||||
|
Text("No data")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func band(
|
||||||
|
from lower: Double,
|
||||||
|
to upper: Double,
|
||||||
|
color: Color,
|
||||||
|
height: CGFloat,
|
||||||
|
maxValue: Double
|
||||||
|
) -> some View {
|
||||||
|
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
|
||||||
|
return color
|
||||||
|
.frame(height: yBottom - yTop)
|
||||||
|
.offset(y: yTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
|
||||||
|
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
|
||||||
|
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
|
||||||
|
return (minT, maxT, 0.0, maxV)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func point(
|
||||||
|
for record: VerificationRecord,
|
||||||
|
minTime: Date,
|
||||||
|
maxTime: Date,
|
||||||
|
minValue: Double,
|
||||||
|
maxValue: Double,
|
||||||
|
width: CGFloat,
|
||||||
|
height: CGFloat,
|
||||||
|
keyPath: KeyPath<VerificationRecord, Double>
|
||||||
|
) -> CGPoint {
|
||||||
|
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
|
||||||
|
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
|
||||||
|
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
|
||||||
|
return CGPoint(x: x, y: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
|
||||||
|
let valueSpan = max(1, maxValue - minValue)
|
||||||
|
return height - height * CGFloat((value - minValue) / valueSpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Dedicated NSOpenPanel / NSSavePanel wrappers (issue #6) — one method
|
||||||
|
/// per purpose, matching the v1 `select_*` commands (docs/21 §Dialogs).
|
||||||
|
/// No call site shares a generic picker (#103/#210/#211).
|
||||||
|
@MainActor
|
||||||
|
final class FileDialogService {
|
||||||
|
|
||||||
|
static let shared = FileDialogService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - selectDirectory
|
||||||
|
|
||||||
|
/// `#btnBrowse` — working directory for Argyll artefacts.
|
||||||
|
/// Defaults to Documents (docs/06 §Empty cwd).
|
||||||
|
func selectDirectory(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
panel.prompt = "Choose"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dedicated open pickers
|
||||||
|
|
||||||
|
/// `selectTargetFile` — **save** panel for the new `.ti1` target.
|
||||||
|
func selectTargetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "target.ti1"
|
||||||
|
panel.allowedContentTypes = utTypes(["ti1"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
panel.message = "Choose the .ti1 target file to create"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectExistingTarget` — open `.ti1`/`.ti2` (docs/06 §Resume, #140).
|
||||||
|
func selectExistingTarget(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti1", "ti2"], startingAt: start,
|
||||||
|
message: "Open an existing target (.ti1 or .ti2)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectProfileFile` — `.icc`/`.icm`/`.mpp` only — **never** `.ti*`
|
||||||
|
/// (#172: the profile filter must not accept datasets).
|
||||||
|
func selectProfileFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["icc", "icm", "mpp"], startingAt: start,
|
||||||
|
message: "Choose an ICC/ICM profile or measurement preconditioning file")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectSpectrumFile` — `.sp` illuminant spectrum (colprof -i).
|
||||||
|
func selectSpectrumFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["sp"], startingAt: start,
|
||||||
|
message: "Choose a custom illuminant spectrum (.sp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectDatasetFile` — open a measured dataset (`.ti3`, `.txt`,
|
||||||
|
/// `.cgats`, `.csv`). Always an *open* dialog, never save (#211).
|
||||||
|
func selectDatasetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["ti3", "txt", "cgats", "csv"], startingAt: start,
|
||||||
|
message: "Import a measured dataset")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCsvSavePath` — verification-history CSV export.
|
||||||
|
func selectCsvSavePath(startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "verification-history.csv"
|
||||||
|
panel.allowedContentTypes = utTypes(["csv"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `selectCalFile` — `.cal` calibration curves.
|
||||||
|
func selectCalFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["cal"], startingAt: start,
|
||||||
|
message: "Choose a calibration file (.cal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnImportPreset` — open a `.json` preset file.
|
||||||
|
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
|
||||||
|
open(extensions: ["json"], startingAt: start,
|
||||||
|
message: "Import a profiling preset (.json)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `btnExportActivePreset` — save a `.json` preset file.
|
||||||
|
func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "\(name).json"
|
||||||
|
panel.allowedContentTypes = utTypes(["json"])
|
||||||
|
panel.allowsOtherFileTypes = false
|
||||||
|
panel.directoryURL = start
|
||||||
|
panel.message = "Export this preset as JSON"
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (private — not a shared public picker API)
|
||||||
|
|
||||||
|
private func open(
|
||||||
|
extensions: [String],
|
||||||
|
startingAt start: URL?,
|
||||||
|
message: String?
|
||||||
|
) -> URL? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.allowedContentTypes = utTypes(extensions)
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.directoryURL = start
|
||||||
|
if let message { panel.message = message }
|
||||||
|
return run(panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func utTypes(_ extensions: [String]) -> [UTType] {
|
||||||
|
extensions.compactMap { UTType(filenameExtension: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSOpenPanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ panel: NSSavePanel) -> URL? {
|
||||||
|
panel.runModal() == .OK ? panel.url : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable help overlay badge that does not reflow layout (#171).
|
||||||
|
///
|
||||||
|
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
|
||||||
|
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
|
||||||
|
/// available on hover, so the overlay is purely a visual cue in help mode.
|
||||||
|
struct HelpOverlay: ViewModifier {
|
||||||
|
let text: String
|
||||||
|
@Binding var showing: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.help(text)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if showing {
|
||||||
|
Image(systemName: "questionmark.circle.fill")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.offset(x: 8, y: -8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Adds a non-reflowing help overlay to the view.
|
||||||
|
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
|
||||||
|
modifier(HelpOverlay(text: text, showing: showing))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,462 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// A single evaluated swatch for the live grid.
|
||||||
|
struct Swatch: Sendable, Equatable, Identifiable {
|
||||||
|
let rowId: String
|
||||||
|
let loc: String
|
||||||
|
let isPad: Bool
|
||||||
|
let intended: DisplayRGB
|
||||||
|
let measured: DisplayRGB
|
||||||
|
let deltaE: Double?
|
||||||
|
let classification: SwatchClassification
|
||||||
|
|
||||||
|
var id: String { "\(rowId)\(loc)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of swatches in display order.
|
||||||
|
struct SwatchRow: Sendable, Equatable, Identifiable {
|
||||||
|
let index: Int
|
||||||
|
let rowId: String
|
||||||
|
let patches: [Swatch]
|
||||||
|
|
||||||
|
var id: String { rowId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage of the XY-table badge bar.
|
||||||
|
enum XYStep: Equatable, Sendable {
|
||||||
|
case place, align, scan, remove
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 3 workflow state and interaction (issues #18–#22).
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MeasurementWorkflowViewModel {
|
||||||
|
|
||||||
|
// MARK: - Authorities
|
||||||
|
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
|
||||||
|
// MARK: - Settings-driven thresholds
|
||||||
|
|
||||||
|
private(set) var goodMax: Double = 2.0
|
||||||
|
private(set) var warningMax: Double = 5.0
|
||||||
|
private(set) var enableLEDs: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
|
var instruments: [InstrumentDevice] = []
|
||||||
|
var selectedInstrument: InstrumentSelection = .auto
|
||||||
|
var isDetecting = false
|
||||||
|
var detectionError: String?
|
||||||
|
|
||||||
|
// MARK: - Chartread session
|
||||||
|
|
||||||
|
var isChartreadRunning = false
|
||||||
|
var chartreadState: ChartreadState = .idle
|
||||||
|
var currentPrompt: String?
|
||||||
|
var requestedWarningKey: String?
|
||||||
|
var chartreadLog: [String] = []
|
||||||
|
var rows: [ChartreadRow] = []
|
||||||
|
var swatchRows: [SwatchRow] = []
|
||||||
|
var showRemoveSheetNotice = false
|
||||||
|
var lastError: String?
|
||||||
|
private var chartreadTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// MARK: - Averaging
|
||||||
|
|
||||||
|
var passSnapshots: [URL] = []
|
||||||
|
var isFinishing = false
|
||||||
|
var finishNotice: String?
|
||||||
|
var finishNoticeIsError = false
|
||||||
|
var resumedFromTi2 = false
|
||||||
|
|
||||||
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
self.wizard = wizard
|
||||||
|
self.environment = environment
|
||||||
|
loadSettings()
|
||||||
|
discoverPassSnapshots()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived state
|
||||||
|
|
||||||
|
var basename: String { wizard.basename }
|
||||||
|
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
|
||||||
|
|
||||||
|
var canDetect: Bool { !isDetecting }
|
||||||
|
|
||||||
|
var canStartRead: Bool {
|
||||||
|
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var canMeasureAnotherSheet: Bool {
|
||||||
|
isFinished && !passSnapshots.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var canFinish: Bool {
|
||||||
|
isFinished && !passSnapshots.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFinished: Bool {
|
||||||
|
chartreadState == .allStripsRead || chartreadState == .finished
|
||||||
|
}
|
||||||
|
|
||||||
|
var xyStep: XYStep {
|
||||||
|
if showRemoveSheetNotice { return .remove }
|
||||||
|
switch chartreadState {
|
||||||
|
case .tablePlaceSheet:
|
||||||
|
return .place
|
||||||
|
case .tableAlign:
|
||||||
|
return .align
|
||||||
|
case .reading, .awaitingStrip:
|
||||||
|
return .scan
|
||||||
|
default:
|
||||||
|
return .place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCanonicalTi3: Bool {
|
||||||
|
guard let cwd = workingDirectory else { return false }
|
||||||
|
let url = cwd.appendingPathComponent("\(basename).ti3")
|
||||||
|
return FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
func loadSettings() {
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
goodMax = settings.deltaEGoodMax
|
||||||
|
warningMax = settings.deltaEWarningMax
|
||||||
|
enableLEDs = settings.enableI1Pro2Leds
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Instrument detection
|
||||||
|
|
||||||
|
func detectInstruments() {
|
||||||
|
guard !isDetecting else { return }
|
||||||
|
isDetecting = true
|
||||||
|
detectionError = nil
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let devices = try await self.environment.runner.detectInstruments()
|
||||||
|
self.instruments = devices
|
||||||
|
if case .device(let selected) = self.selectedInstrument,
|
||||||
|
!devices.contains(where: { $0.port == selected.port }) {
|
||||||
|
self.selectedInstrument = .auto
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.detectionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isDetecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chartread lifecycle
|
||||||
|
|
||||||
|
func startRead() {
|
||||||
|
guard canStartRead, let cwd = workingDirectory else { return }
|
||||||
|
let config = buildChartreadConfig(cwd: cwd)
|
||||||
|
startChartread(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureAnotherSheet() {
|
||||||
|
guard let cwd = workingDirectory, isFinished else { return }
|
||||||
|
let config = buildChartreadConfig(cwd: cwd)
|
||||||
|
startChartread(config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
|
||||||
|
ChartreadConfig(
|
||||||
|
basename: basename,
|
||||||
|
workingDirectory: cwd,
|
||||||
|
selectedPort: selectedInstrument.chartreadPort,
|
||||||
|
enableLEDs: enableLEDs,
|
||||||
|
isXY: selectedInstrument.isXY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startChartread(config: ChartreadConfig) {
|
||||||
|
guard !isChartreadRunning else { return }
|
||||||
|
|
||||||
|
isChartreadRunning = true
|
||||||
|
chartreadState = .idle
|
||||||
|
currentPrompt = nil
|
||||||
|
lastError = nil
|
||||||
|
chartreadLog.removeAll()
|
||||||
|
|
||||||
|
// Optional: reset rows when starting a fresh first pass.
|
||||||
|
if passSnapshots.isEmpty {
|
||||||
|
rows.removeAll()
|
||||||
|
swatchRows.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream = environment.runner.runChartread(config: config)
|
||||||
|
|
||||||
|
chartreadTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for await event in stream {
|
||||||
|
self.handle(event: event)
|
||||||
|
}
|
||||||
|
self.isChartreadRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: ChartreadEvent) {
|
||||||
|
switch event {
|
||||||
|
case .prompt(let result):
|
||||||
|
chartreadState = result.state
|
||||||
|
currentPrompt = promptText(for: result)
|
||||||
|
requestedWarningKey = result.requestedWarningKey
|
||||||
|
showRemoveSheetNotice = result.isRemoveSheetNotice
|
||||||
|
|
||||||
|
case .row(let row):
|
||||||
|
upsert(row: row)
|
||||||
|
if row.isFinalRow {
|
||||||
|
chartreadState = .allStripsRead
|
||||||
|
}
|
||||||
|
|
||||||
|
case .log(let batch):
|
||||||
|
chartreadLog.append(contentsOf: batch)
|
||||||
|
|
||||||
|
case .removeSheetNotice:
|
||||||
|
showRemoveSheetNotice = true
|
||||||
|
|
||||||
|
case .exit(let code):
|
||||||
|
if code != 0 {
|
||||||
|
lastError = "chartread exited with code \(code)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case .completed(let canonicalURL):
|
||||||
|
chartreadState = .finished
|
||||||
|
completePass(canonicalURL: canonicalURL)
|
||||||
|
|
||||||
|
case .failed(let error):
|
||||||
|
lastError = error.localizedDescription
|
||||||
|
chartreadState = .error
|
||||||
|
isChartreadRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func promptText(for result: ChartreadClassifyResult) -> String {
|
||||||
|
switch result.state {
|
||||||
|
case .calibrating:
|
||||||
|
return "Place instrument on calibration tile and press Calibrate."
|
||||||
|
case .awaitingStrip:
|
||||||
|
return "Press a key to read the next strip."
|
||||||
|
case .allStripsRead:
|
||||||
|
return "All strips read. Press Done & Save when ready."
|
||||||
|
case .warning:
|
||||||
|
if let key = result.requestedWarningKey {
|
||||||
|
return "Warning — press '\(key.uppercased())' to continue."
|
||||||
|
}
|
||||||
|
return "Warning — press Continue."
|
||||||
|
case .promptContinue:
|
||||||
|
return "Press Continue."
|
||||||
|
case .tablePlaceSheet:
|
||||||
|
if let n = result.sheetNumber, let t = result.sheetTotal {
|
||||||
|
return "Place sheet \(n) of \(t) on the table."
|
||||||
|
}
|
||||||
|
return "Place the sheet on the table."
|
||||||
|
case .tableAlign:
|
||||||
|
if let patch = result.alignmentPatch {
|
||||||
|
return "Locate patch \(patch) with the sight, then continue."
|
||||||
|
}
|
||||||
|
return "Align the fiducial, then continue."
|
||||||
|
case .reading:
|
||||||
|
return "Reading..."
|
||||||
|
case .error:
|
||||||
|
return "Read error — you can Retry or Cancel."
|
||||||
|
case .finished:
|
||||||
|
return "Measurement saved."
|
||||||
|
case .idle:
|
||||||
|
return "Press Start to begin reading."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User actions
|
||||||
|
|
||||||
|
func calibrate() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accept() {
|
||||||
|
if let key = requestedWarningKey {
|
||||||
|
send(.customKey(key))
|
||||||
|
requestedWarningKey = nil
|
||||||
|
} else {
|
||||||
|
send(.accept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retry() {
|
||||||
|
send(.trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doneAndSave() {
|
||||||
|
send(.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelRead() {
|
||||||
|
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
|
||||||
|
chartreadTask?.cancel()
|
||||||
|
isChartreadRunning = false
|
||||||
|
chartreadState = .idle
|
||||||
|
currentPrompt = nil
|
||||||
|
requestedWarningKey = nil
|
||||||
|
showRemoveSheetNotice = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendWarningKey(_ key: String) {
|
||||||
|
send(.customKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ input: ChartreadInput) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.isChartreadRunning else { return }
|
||||||
|
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows and swatches
|
||||||
|
|
||||||
|
private func upsert(row: ChartreadRow) {
|
||||||
|
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
|
||||||
|
rows[index] = row
|
||||||
|
} else {
|
||||||
|
rows.append(row)
|
||||||
|
}
|
||||||
|
rows.sort { $0.rowIndex < $1.rowIndex }
|
||||||
|
recomputeSwatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recomputeSwatches() {
|
||||||
|
var displayRows: [SwatchRow] = []
|
||||||
|
for (rowIndex, row) in rows.enumerated() {
|
||||||
|
var swatches: [Swatch] = []
|
||||||
|
for patch in row.patches {
|
||||||
|
let skip = shouldSkipPad(patch)
|
||||||
|
if skip { continue }
|
||||||
|
|
||||||
|
let eval = ColorDifference.evaluate(
|
||||||
|
patch: patch,
|
||||||
|
goodMax: goodMax,
|
||||||
|
warningMax: warningMax
|
||||||
|
)
|
||||||
|
if let eval {
|
||||||
|
swatches.append(Swatch(
|
||||||
|
rowId: row.rowId,
|
||||||
|
loc: patch.loc,
|
||||||
|
isPad: patch.isPad,
|
||||||
|
intended: eval.intended,
|
||||||
|
measured: eval.measured,
|
||||||
|
deltaE: eval.deltaE,
|
||||||
|
classification: eval.classification
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !swatches.isEmpty {
|
||||||
|
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
swatchRows = displayRows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
|
||||||
|
guard patch.isPad else { return false }
|
||||||
|
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
|
||||||
|
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
|
||||||
|
return measuredEmpty && deviceAllZero
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pass management
|
||||||
|
|
||||||
|
private func completePass(canonicalURL: URL) {
|
||||||
|
guard let cwd = workingDirectory else { return }
|
||||||
|
do {
|
||||||
|
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
|
||||||
|
discoverPassSnapshots()
|
||||||
|
wizard.refreshGating()
|
||||||
|
} catch {
|
||||||
|
lastError = "Could not snapshot pass: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverPassSnapshots() {
|
||||||
|
guard let cwd = workingDirectory else {
|
||||||
|
passSnapshots = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finish / Average
|
||||||
|
|
||||||
|
func finishAndAverage() {
|
||||||
|
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
|
||||||
|
isFinishing = true
|
||||||
|
finishNotice = nil
|
||||||
|
finishNoticeIsError = false
|
||||||
|
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let canonical: URL
|
||||||
|
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
|
||||||
|
canonical = try MeasurementArtefacts.promotePass(
|
||||||
|
pass: pass,
|
||||||
|
basename: self.basename,
|
||||||
|
cwd: cwd
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let config = AverageConfig(
|
||||||
|
workingDirectory: cwd,
|
||||||
|
basename: self.basename,
|
||||||
|
passFiles: self.passSnapshots
|
||||||
|
)
|
||||||
|
canonical = try await self.environment.runner.runAverage(
|
||||||
|
config: config,
|
||||||
|
onLogBatch: { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.chartreadLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.discoverPassSnapshots()
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
if self.wizard.isUnlocked(.buildProfile) {
|
||||||
|
self.wizard.go(to: .buildProfile)
|
||||||
|
} else {
|
||||||
|
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to pass 1 promotion if averaging failed.
|
||||||
|
if let pass = self.passSnapshots.first {
|
||||||
|
do {
|
||||||
|
_ = try MeasurementArtefacts.promotePass(
|
||||||
|
pass: pass,
|
||||||
|
basename: self.basename,
|
||||||
|
cwd: cwd
|
||||||
|
)
|
||||||
|
self.discoverPassSnapshots()
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.finishNotice = "Averaging failed — promoted first pass."
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
} catch {
|
||||||
|
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.finishNotice = "Finish failed: \(error.localizedDescription)"
|
||||||
|
self.finishNoticeIsError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.isFinishing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,506 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// User-facing FWA selection for the Stage 4 form.
|
||||||
|
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
|
||||||
|
case none = "none"
|
||||||
|
case empty = ""
|
||||||
|
case D50 = "D50"
|
||||||
|
case D65 = "D65"
|
||||||
|
case custom = "custom"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .empty: return "Bare (-f)"
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return "Custom .sp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ProfileWorkflowViewModel {
|
||||||
|
|
||||||
|
let wizard: WizardViewModel
|
||||||
|
let environment: AppEnvironment
|
||||||
|
private let fileDialogs = FileDialogService.shared
|
||||||
|
|
||||||
|
// MARK: - Stage 4 form
|
||||||
|
|
||||||
|
var algorithm: String = "l" // l | x | X | m
|
||||||
|
var quality: String = "m" // l | m | h | u
|
||||||
|
var intent: String = "" // usually empty at Stage 4
|
||||||
|
var fwaSelection: ColprofFwaSelection = .none
|
||||||
|
var fwaCustomPath: String = ""
|
||||||
|
var illuminant: String = ""
|
||||||
|
var observer: String = ""
|
||||||
|
var inputViewingCond: String = ""
|
||||||
|
var outputViewingCond: String = ""
|
||||||
|
var profileDescription: String = ""
|
||||||
|
var copyright: String = ""
|
||||||
|
|
||||||
|
// MARK: - Run state
|
||||||
|
|
||||||
|
var isColprofRunning = false
|
||||||
|
var colprofLog: [String] = []
|
||||||
|
var colprofProgress: String?
|
||||||
|
var lastError: String?
|
||||||
|
var createdProfileURL: URL?
|
||||||
|
|
||||||
|
// MARK: - Stage 4/5 calibration (issue #24)
|
||||||
|
|
||||||
|
var applyCalibration = false
|
||||||
|
var calibrationFile: String = ""
|
||||||
|
|
||||||
|
// MARK: - Stage 5 verification (issue #25)
|
||||||
|
|
||||||
|
var profcheckReport: ProfcheckReport?
|
||||||
|
var profcheckWarning: String?
|
||||||
|
var isProfcheckRunning = false
|
||||||
|
|
||||||
|
// MARK: - History / drift (issue #26)
|
||||||
|
|
||||||
|
var verificationHistory: [VerificationRecord] = []
|
||||||
|
var driftPrinterFilter: String? = nil
|
||||||
|
var driftAlert: String?
|
||||||
|
var isHistoryStoreError: String?
|
||||||
|
|
||||||
|
// MARK: - Install (issue #27)
|
||||||
|
|
||||||
|
var installResult: InstallProfileResult?
|
||||||
|
var showingInstallCollision = false
|
||||||
|
var installCollisionMessage: String = ""
|
||||||
|
var pendingInstallOptions: InstallProfileOptions?
|
||||||
|
|
||||||
|
init(wizard: WizardViewModel, environment: AppEnvironment) {
|
||||||
|
self.wizard = wizard
|
||||||
|
self.environment = environment
|
||||||
|
restoreCreatedProfileURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores `createdProfileURL` from the wizard artefacts or by probing
|
||||||
|
/// the working directory for an existing `.icc`/`.icm` (#52).
|
||||||
|
func restoreCreatedProfileURL() {
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
|
||||||
|
createdProfileURL = wizard.artefacts.profilePath
|
||||||
|
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var canCreateProfile: Bool {
|
||||||
|
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var canVerify: Bool {
|
||||||
|
createdProfileURL != nil && !isProfcheckRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
var fwaValue: String? {
|
||||||
|
switch fwaSelection {
|
||||||
|
case .none: return nil
|
||||||
|
case .empty: return ""
|
||||||
|
case .D50: return "D50"
|
||||||
|
case .D65: return "D65"
|
||||||
|
case .custom: return fwaCustomPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preset application
|
||||||
|
|
||||||
|
func applyPreset(_ preset: ProfilingPreset?) {
|
||||||
|
guard let preset else { return }
|
||||||
|
algorithm = preset.colprofAlgorithm ?? "l"
|
||||||
|
quality = preset.colprofQuality ?? "m"
|
||||||
|
intent = preset.colprofIntent ?? ""
|
||||||
|
|
||||||
|
if let fwa = preset.colprofFwa {
|
||||||
|
switch fwa.lowercased() {
|
||||||
|
case "none": fwaSelection = .none
|
||||||
|
case "": fwaSelection = .empty
|
||||||
|
case "d50": fwaSelection = .D50
|
||||||
|
case "d65": fwaSelection = .D65
|
||||||
|
default:
|
||||||
|
fwaSelection = .custom
|
||||||
|
fwaCustomPath = fwa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
illuminant = preset.colprofIlluminant ?? ""
|
||||||
|
observer = preset.colprofObserver ?? ""
|
||||||
|
inputViewingCond = preset.colprofInputViewingCond ?? ""
|
||||||
|
outputViewingCond = preset.colprofOutputViewingCond ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 4 form values for saving into a custom preset.
|
||||||
|
func presetSnapshot() -> (
|
||||||
|
algorithm: String,
|
||||||
|
quality: String,
|
||||||
|
intent: String?,
|
||||||
|
fwa: String?,
|
||||||
|
illuminant: String?,
|
||||||
|
observer: String?,
|
||||||
|
inputViewingCond: String?,
|
||||||
|
outputViewingCond: String?
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
algorithm: algorithm,
|
||||||
|
quality: quality,
|
||||||
|
intent: intent.isEmpty ? nil : intent,
|
||||||
|
fwa: fwaValue,
|
||||||
|
illuminant: illuminant.isEmpty ? nil : illuminant,
|
||||||
|
observer: observer.isEmpty ? nil : observer,
|
||||||
|
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
|
||||||
|
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 4: build profile
|
||||||
|
|
||||||
|
func buildColprofConfig() -> ColprofConfig {
|
||||||
|
let description = profileDescription.isEmpty ? wizard.basename : profileDescription
|
||||||
|
return ColprofConfig(
|
||||||
|
algorithm: algorithm,
|
||||||
|
quality: quality,
|
||||||
|
intent: intent.isEmpty ? nil : intent,
|
||||||
|
fwa: fwaValue,
|
||||||
|
illuminant: illuminant.isEmpty ? nil : illuminant,
|
||||||
|
observer: observer.isEmpty ? nil : observer,
|
||||||
|
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
|
||||||
|
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond,
|
||||||
|
description: description,
|
||||||
|
copyright: copyright.isEmpty ? nil : copyright,
|
||||||
|
basename: wizard.basename,
|
||||||
|
workingDirectory: wizard.effectiveWorkingDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProfile() {
|
||||||
|
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
let config = buildColprofConfig()
|
||||||
|
|
||||||
|
isColprofRunning = true
|
||||||
|
colprofLog = []
|
||||||
|
colprofProgress = nil
|
||||||
|
lastError = nil
|
||||||
|
createdProfileURL = nil
|
||||||
|
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isColprofRunning = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = try await runner.runColprof(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.colprofLog.append(contentsOf: batch)
|
||||||
|
if let last = batch.last {
|
||||||
|
let progress = ColprofProgressClassifier.classify(line: last)
|
||||||
|
self.updateProgress(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalProfileURL = url
|
||||||
|
|
||||||
|
if self.applyCalibration, !self.calibrationFile.isEmpty {
|
||||||
|
let applyConfig = ApplycalConfig(
|
||||||
|
calibrationPath: self.calibrationFile,
|
||||||
|
inputProfileURL: url
|
||||||
|
)
|
||||||
|
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
|
||||||
|
finalProfileURL = try await runner.runApplycal(config: applyConfig)
|
||||||
|
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
|
||||||
|
do {
|
||||||
|
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
|
||||||
|
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.colprofLog.append("Gamut mesh extracted.")
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Gamut extraction skipped: \(error.localizedDescription)",
|
||||||
|
kind: .info
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.createdProfileURL = finalProfileURL
|
||||||
|
self.wizard.refreshGating()
|
||||||
|
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
|
||||||
|
self.wizard.go(to: .verifyInstall)
|
||||||
|
} catch {
|
||||||
|
self.lastError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Profile creation failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress(_ progress: ColprofProgress) {
|
||||||
|
switch progress {
|
||||||
|
case .gamutMapping:
|
||||||
|
colprofProgress = "Gamut mapping calculation…"
|
||||||
|
case .fittingClut:
|
||||||
|
colprofProgress = "Fitting cLUT grid points…"
|
||||||
|
case .writingIcc:
|
||||||
|
colprofProgress = "Writing ICC profile…"
|
||||||
|
case .unknown:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stage 5: verify profile
|
||||||
|
|
||||||
|
var knownPrinters: [String] {
|
||||||
|
var names = Set<String>()
|
||||||
|
for record in verificationHistory {
|
||||||
|
if record.printerName.isEmpty {
|
||||||
|
names.insert("Unknown")
|
||||||
|
} else {
|
||||||
|
names.insert(record.printerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array(names).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadHistory() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
self.verificationHistory = try await self.environment.historyStore.load()
|
||||||
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
|
} catch {
|
||||||
|
self.isHistoryStoreError = error.localizedDescription
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Could not load verification history: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredHistory: [VerificationRecord] {
|
||||||
|
guard let filter = driftPrinterFilter, !filter.isEmpty else {
|
||||||
|
return verificationHistory
|
||||||
|
}
|
||||||
|
return verificationHistory.filter { $0.printerName == filter }
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyProfile() {
|
||||||
|
guard canVerify,
|
||||||
|
let cwd = wizard.effectiveWorkingDirectory,
|
||||||
|
let profileURL = createdProfileURL else { return }
|
||||||
|
|
||||||
|
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
|
||||||
|
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
|
||||||
|
|
||||||
|
isProfcheckRunning = true
|
||||||
|
profcheckReport = nil
|
||||||
|
profcheckWarning = nil
|
||||||
|
|
||||||
|
let runner = environment.runner
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer { self.isProfcheckRunning = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.colprofLog.append(contentsOf: batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.profcheckReport = report
|
||||||
|
if let record = self.makeVerificationRecord(from: report) {
|
||||||
|
let updated = try await self.environment.historyStore.append(record)
|
||||||
|
self.verificationHistory = updated
|
||||||
|
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
|
||||||
|
}
|
||||||
|
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
|
||||||
|
self.profcheckWarning = "profcheck output could not be parsed."
|
||||||
|
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
|
||||||
|
} catch {
|
||||||
|
self.profcheckWarning = error.localizedDescription
|
||||||
|
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Verification failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? {
|
||||||
|
guard let avg = report.avgDE,
|
||||||
|
let max = report.maxDE,
|
||||||
|
let rms = report.rmsDE,
|
||||||
|
let status = report.status else { return nil }
|
||||||
|
|
||||||
|
let timestamp = Date()
|
||||||
|
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
|
||||||
|
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
|
||||||
|
return VerificationRecord(
|
||||||
|
id: id,
|
||||||
|
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
|
||||||
|
printerName: printerName,
|
||||||
|
avgDE: avg,
|
||||||
|
maxDE: max,
|
||||||
|
rmsDE: rms,
|
||||||
|
patchCount: report.patchCount ?? 0,
|
||||||
|
status: status,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextSeq() -> Int {
|
||||||
|
Int.random(in: 0..<1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.environment.historyStore.clear()
|
||||||
|
self.verificationHistory = []
|
||||||
|
self.driftAlert = nil
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Could not clear history: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Profile install
|
||||||
|
|
||||||
|
func beginInstallProfile() {
|
||||||
|
guard let sourceURL = createdProfileURL,
|
||||||
|
let _ = wizard.effectiveWorkingDirectory else { return }
|
||||||
|
|
||||||
|
let settings = environment.settingsStore.load()
|
||||||
|
let preferSystem = settings.defaultInstallLocation == .system
|
||||||
|
let options = InstallProfileOptions(
|
||||||
|
forceOverwrite: !settings.askBeforeOverwriteProfile,
|
||||||
|
preferSystem: preferSystem,
|
||||||
|
collisionPolicy: .overwrite,
|
||||||
|
openColorPanel: settings.openColorPanelAfterInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
|
||||||
|
let destURL = try ProfileInstaller.resolveDestinationURL(for: config)
|
||||||
|
let collision = FileManager.default.fileExists(atPath: destURL.path)
|
||||||
|
|
||||||
|
if collision && settings.askBeforeOverwriteProfile {
|
||||||
|
pendingInstallOptions = options
|
||||||
|
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
|
||||||
|
showingInstallCollision = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runInstall(sourceURL: sourceURL, options: options)
|
||||||
|
} catch {
|
||||||
|
wizard.showNotice(
|
||||||
|
"Install failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
|
||||||
|
showingInstallCollision = false
|
||||||
|
guard let sourceURL = createdProfileURL,
|
||||||
|
var options = pendingInstallOptions else { return }
|
||||||
|
|
||||||
|
if policy == .cancel {
|
||||||
|
installResult = InstallProfileResult(
|
||||||
|
destPath: "",
|
||||||
|
registered: false,
|
||||||
|
overwritten: false,
|
||||||
|
renamed: false,
|
||||||
|
openedPanel: false,
|
||||||
|
message: "Install cancelled."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.collisionPolicy = policy
|
||||||
|
if policy == .overwrite {
|
||||||
|
options.forceOverwrite = true
|
||||||
|
}
|
||||||
|
runInstall(sourceURL: sourceURL, options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
|
||||||
|
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
|
||||||
|
Task(priority: .userInitiated) { [weak self] in
|
||||||
|
do {
|
||||||
|
let result = try ProfileInstaller.install(config: config)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.installResult = result
|
||||||
|
self?.wizard.showNotice(result.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.wizard.showNotice(
|
||||||
|
"Install failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportHistory() {
|
||||||
|
guard let url = fileDialogs.selectCsvSavePath() else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let csv = await self.environment.historyStore.exportCSV()
|
||||||
|
do {
|
||||||
|
try csv.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
self.wizard.showNotice("History exported: \(url.lastPathComponent)")
|
||||||
|
} catch {
|
||||||
|
self.wizard.showNotice(
|
||||||
|
"Export failed: \(error.localizedDescription)",
|
||||||
|
kind: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File pickers
|
||||||
|
|
||||||
|
func browseForSpectrumFile() {
|
||||||
|
let start = wizard.effectiveWorkingDirectory
|
||||||
|
let url = UITestHooks.isEnabled
|
||||||
|
? nil
|
||||||
|
: fileDialogs.selectSpectrumFile(startingAt: start)
|
||||||
|
if let url {
|
||||||
|
fwaSelection = .custom
|
||||||
|
fwaCustomPath = url.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseForCalibrationFile() {
|
||||||
|
let start = wizard.effectiveWorkingDirectory
|
||||||
|
let url = fileDialogs.selectCalFile(startingAt: start)
|
||||||
|
if let url {
|
||||||
|
calibrationFile = url.path
|
||||||
|
applyCalibration = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
@State private var showingAllHelp = false
|
||||||
|
|
||||||
|
private var model: WizardViewModel { workflow.wizard }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
SidebarView(
|
||||||
|
workflow: workflow,
|
||||||
|
onOpenSettings: { showingSettings = true },
|
||||||
|
onOpenAbout: { showingAbout = true },
|
||||||
|
showingAllHelp: $showingAllHelp
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAbout) {
|
||||||
|
AboutView { showingAbout = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var stageContent: some View {
|
||||||
|
switch model.stage {
|
||||||
|
case .generate:
|
||||||
|
Stage1View(workflow: workflow)
|
||||||
|
case .layOutPrint:
|
||||||
|
Stage2View(workflow: workflow)
|
||||||
|
case .measure:
|
||||||
|
Stage3View(model: workflow.measurement)
|
||||||
|
case .buildProfile:
|
||||||
|
Stage4View(model: workflow.profile)
|
||||||
|
case .verifyInstall:
|
||||||
|
Stage5View(model: workflow.profile)
|
||||||
|
default:
|
||||||
|
StagePlaceholderView(stage: model.stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
|
||||||
|
/// the full v1 field set; ΔE validation shows inline under the fields.
|
||||||
|
struct SettingsView: View {
|
||||||
|
@State var model = SettingsViewModel()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private static let instruments: [(code: String, label: String)] = [
|
||||||
|
("i1", "X-Rite i1Pro / i1Pro 2"),
|
||||||
|
("p3", "X-Rite i1Pro 3 / 3 Plus"),
|
||||||
|
("CM", "ColorMunki"),
|
||||||
|
("SS", "Specbos / Spectraval"),
|
||||||
|
("20", "Gretag i1Display 2"),
|
||||||
|
("22", "X-Rite i1Display Pro / ColorMunki Display"),
|
||||||
|
("41", "Datacolor Spyder 4/5"),
|
||||||
|
("51", "Spyder X"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Form {
|
||||||
|
Section("Argyll") {
|
||||||
|
HStack {
|
||||||
|
TextField(
|
||||||
|
"Bundled sidecars",
|
||||||
|
text: Binding(
|
||||||
|
get: { model.settings.argyllBinaryDir ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.argyllBinaryDir =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Button("Browse…") {
|
||||||
|
if let dir = FileDialogService.shared.selectDirectory() {
|
||||||
|
model.settings.argyllBinaryDir = dir.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Leave empty to use the bundled Argyll tools.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
"Default instrument",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.defaultInstrument ?? "" },
|
||||||
|
set: {
|
||||||
|
model.settings.defaultInstrument =
|
||||||
|
$0.isEmpty ? nil : $0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("None").tag("")
|
||||||
|
ForEach(Self.instruments, id: \.code) {
|
||||||
|
Text($0.label).tag($0.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Display-only — Stage 2's instrument select is used for actual runs.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
"Enable i1Pro 2 LEDs",
|
||||||
|
isOn: $model.settings.enableI1Pro2Leds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Verification") {
|
||||||
|
HStack {
|
||||||
|
Text("Good ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"2.0",
|
||||||
|
value: $model.settings.deltaEGoodMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("Warning ΔE ≤")
|
||||||
|
TextField(
|
||||||
|
"5.0",
|
||||||
|
value: $model.settings.deltaEWarningMax,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
ForEach(model.validationErrors, id: \.self) { error in
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Calibration") {
|
||||||
|
HStack {
|
||||||
|
Text("Stale after")
|
||||||
|
TextField(
|
||||||
|
"30",
|
||||||
|
value: $model.settings.calibrationStaleDays,
|
||||||
|
format: .number
|
||||||
|
)
|
||||||
|
.frame(width: 60)
|
||||||
|
Text("days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Profile install") {
|
||||||
|
Picker(
|
||||||
|
"Install location",
|
||||||
|
selection: $model.settings.defaultInstallLocation
|
||||||
|
) {
|
||||||
|
Text("User library").tag(InstallLocation.user)
|
||||||
|
Text("System library").tag(InstallLocation.system)
|
||||||
|
}
|
||||||
|
Toggle(
|
||||||
|
"Ask before overwriting a profile",
|
||||||
|
isOn: $model.settings.askBeforeOverwriteProfile
|
||||||
|
)
|
||||||
|
Toggle(
|
||||||
|
"Open ColorSync after install",
|
||||||
|
isOn: $model.settings.openColorPanelAfterInstall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Logging") {
|
||||||
|
Picker(
|
||||||
|
"Log level",
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.settings.logLevel },
|
||||||
|
set: { model.settings.logLevel = $0 }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Default").tag(LogLevel?.none)
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) {
|
||||||
|
Text($0.rawValue.capitalized).tag(LogLevel?.some($0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Open log folder") { model.openLogFolder() }
|
||||||
|
Button("Copy path") { model.copyLogPath() }
|
||||||
|
Button("Copy excerpt") { model.copyLogExcerpt() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if model.savedFlash {
|
||||||
|
Text("Saved")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Button("Save") {
|
||||||
|
if model.save() { dismiss() }
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.frame(width: 560, height: 620)
|
||||||
|
.background(Theme.background)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Backs the Settings sheet (issue #5). Load → edit → save with
|
||||||
|
/// validation; the log level is applied live via `LogSink` (#158) and a
|
||||||
|
/// `settingsDidChange` notification fans out to #20.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel {
|
||||||
|
|
||||||
|
var settings: AppSettings
|
||||||
|
var validationErrors: [String] = []
|
||||||
|
var savedFlash = false
|
||||||
|
|
||||||
|
private let store: SettingsStore
|
||||||
|
private let sink: LogSink
|
||||||
|
|
||||||
|
init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) {
|
||||||
|
self.store = store
|
||||||
|
self.sink = sink
|
||||||
|
self.settings = store.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists after validation. Returns false (and shows inline
|
||||||
|
/// errors) when the form is invalid.
|
||||||
|
@discardableResult
|
||||||
|
func save() -> Bool {
|
||||||
|
validationErrors = settings.validate()
|
||||||
|
guard validationErrors.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
try store.save(settings)
|
||||||
|
sink.applySettings(settings)
|
||||||
|
savedFlash = true
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
savedFlash = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
validationErrors = ["Could not save settings: \(error.localizedDescription)"]
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log helpers
|
||||||
|
|
||||||
|
var logFileURL: URL { AppPaths.logFile }
|
||||||
|
|
||||||
|
func openLogFolder() {
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: AppPaths.logDir, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
NSWorkspace.shared.selectFile(
|
||||||
|
AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogPath() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLogExcerpt() {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user