Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 65ba6dc61a M2: targen/printtarg workflow, resume, gallery, presets (#7–#11)
Stage 1 targen: typed TargenConfig + argv builder (-v -d 2/4, -f, -e,
-B, optional advanced flags, CMYK-only -l, never -u), ArgyllRunner over
ProcessManager, Stage1View with disk-gated Generate.

Issue 8: Open Existing resume — .ti1 → Stage 2, .ti2 → Stage 3 shell
with persistent "Resumed from .ti2" banner, sibling-.ti1 gate.

Stage 2 printtarg: PrinttargConfig/args (-v -u, -i, -p, -R 1 default,
-r raster, -t/-T DPI), Stage2View with instrument/page/DPI/label
controls, CM warning, stubbed print panel.

Issue 10: pretty-manifest JSON parsing, host-side TIFF→PNG gallery
previews, per-page stubbed print, failure stays on stage.

Issue 11: typed ProfilingPreset schema with legacy CustomPreset
migration, four built-ins, save/manage/import/export UI, DPI binding.

Tests: 124 Core tests (Swift Testing) + 11 Milestone2UITests (XCTest)
with committed fixture sidecars — all green on arm64+x86_64 universal
build. No hardware, no downloads.

Also: ProcessError LocalizedError conformance; accessibilityElement
.contain on container identifiers so child AX ids survive.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 23:12:22 +01:00
gronod c0f5fb8c28 Merge pull request 'M1 — Foundation & process core' (#40) from milestone/m1-foundation into develop 2026-09-08 19:32:35 +01:00
gronod dc62f5c016 Merge pull request 'Wizard state machine & artefact gating (#4)' (#39) from feat/4-wizard-gating into milestone/m1-foundation 2026-09-08 19:30:21 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7385cf1640 Wizard state machine & artefact gating (#4)
- WizardState: Codable persisted fields — currentStage, basename, cwd,
  printerName, sessionMode (profile|calibration), profileBasename;
  WizardStateStore writes wizard_state.json atomically
- WizardGating: pure gating — 1 always; 2 on .ti1; 3 on .ti1+.ti2; 4 on
  .ti3 only (never .ti2 — #109/#110); 5 on .ti3+profile (#69). Forward
  gated, backward always; Stage 0 a side-trip
- WizardViewModel: setTarget sanitises basename (#60) and resolves cwd
  (#59); locked forward nav shows a warning banner; windowDidBecomeKey
  re-probes and drops back to deepest unlocked when files vanish (#151)
- RootView: NSWindow.didBecomeKeyNotification -> revalidate
- Sidebar stepper enabled state now driven by artefact gating
- 11 new tests incl. gating matrix + persistence; 71/71 green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:29:59 +01:00
gronod ea9409ddd4 Merge pull request 'Settings store, logging & settings dialog (#5)' (#38) from feat/5-settings-logging into milestone/m1-foundation 2026-09-08 19:14:39 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ee16fb3fae Settings store, logging & settings dialog (#5)
- AppSettings: snake_case Codable model — argyll_binary_dir,
  default_instrument (stored, never applied to argv), log_level (nil ->
  Debug debug / Info release), delta_e thresholds (2.0/5.0),
  custom_presets, enable_i1pro2_leds, calibration_stale_days 30,
  default_install_location user, ask_before_overwrite_profile,
  open_color_panel_after_install
- Validation with the exact contract strings; save() refuses invalid
  settings; corrupt/missing JSON -> defaults; settingsDidChange
  notification posted on save (for #20)
- LogSink: rolling file at ~/Library/Logs/com.gronod.iccery2/
  iccery.log, 5 MiB x 5 segments, runtime setLevel applied at startup
  and on save (#158); AppLogger gates os_log+file through it
- SettingsView sheet: Argyll dir picker, instrument (display-only
  caveat), i1Pro2 LEDs, ΔE fields + inline errors, stale days, install
  location, overwrite + ColorSync toggles, log level, open-log-folder /
  copy-path / copy-excerpt
- v1 settings path never read; writes atomic via AtomicFileWriter
- 13 new tests; 60/60 green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:14:14 +01:00
gronod 933eadd1c3 Merge pull request 'File dialogs — dedicated pickers + host helpers (#6, part 2)' (#37) from feat/6-file-dialogs into milestone/m1-foundation 2026-09-08 19:09:41 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2a608c8962 File dialogs & artefact helpers — dedicated pickers + host helpers (#6)
- One dedicated method per purpose: selectTargetFile (save .ti1),
  selectExistingTarget (ti1/ti2), selectProfileFile (icc/icm/mpp —
  never ti*, #172), selectSpectrumFile (.sp), selectDatasetFile
  (open-only ti3/txt/cgats/csv, #211), selectCsvSavePath, selectCalFile,
  selectDirectory. No shared generic picker API (#103/#210/#211).
- Ti2Header: TARGET_INSTRUMENT / NUMBER_OF_SETS / NUMBER_OF_PAGES +
  sibling .ti1 detection; NUMBER_OF_FIELDS explicitly not patch count
- TiffPreview: host-side TIFF→PNG thumbnail, 1200px max edge (#58)
- ArtefactFiles: defaultWorkingDirectory, readBase64, appInfo
- 7 new tests; 47/47 green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:09:19 +01:00
gronod 3d206e27c1 Merge pull request 'File dialogs & artefact helpers (#6)' (#36) from feat/6-file-dialogs into milestone/m1-foundation 2026-09-08 19:06:27 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 552227c3af File dialogs & artefact helpers (#6)
- PathSecurity: basename sanitisation (reject / \ .., empty); resolveSafeCwd explicit - Documents - Home - app-data (#59, #60)
- AtomicFileWriter: .tmp + rename/replaceItemAt, parent dirs (#213)
- ArtefactProbe: verify_stage_artefacts, .icm-over-.icc resolution (#69), enumeration incl. .N.tif/_NN.tif/_passN.ti3/CAL_
- FileDialogService: NSOpenPanel/NSSavePanel wrappers (v2 select_* equivalents)
- 11 new tests; 40/40 total green

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:06:10 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5ed3ff5428 fetch-argyll: ad-hoc sign + verify; ship real sRGB.gam (#3 follow-up)
- codesign -f -s - every fetched Mach-O, then codesign -dvv verify —
  an unsigned sidecar now fails the script (#165)
- Replace the 8-cusp reference_gamuts stub with the real v0.8.5
  src/assets/sRGB.gam (448 verts / 892 faces) used by the Stage 5
  gamut overlay (#185)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:02:49 +01:00
gronod 6b122b2cbc Merge pull request 'Argyll sidecar fetch & binary resolution (#3)' (#35) from feat/3-argyll-resolution into milestone/m1-foundation 2026-09-08 19:01:01 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 716b302374 Argyll sidecar fetch & binary resolution (#3)
- scripts/fetch-argyll.sh: POSIX sh port of fetch-argyll.mjs; queries
  the Gitea release API for the *_macOS_universal_bin.tgz asset,
  extracts Argyll_V*/bin, chmod+x, strips quarantine xattr; honors
  ARGYLL_SERVER_URL / ARGYLL_REPO / ARGYLL_RELEASE_TAG / GITEA_TOKEN.
  Verified end-to-end: 51 universal tools from v3.5.0-ICCery1.8.
- BinaryResolver: settings argyll_binary_dir override (existence-gated)
  → bundled Argyll/<platform>/, macos-universal preferred when instlist
  marker present, else macos-arm64/macos-x86_64; constructed path
  returned even when absent (spawn surfaces process:error). Mock and
  reference-gamut helpers.
- Vendored tracked resources: mocks/{chartread,colprof,profcheck}.mock
  + reference_gamuts/sRGB.gam from ICCery v1, copied as a folder
  reference so the Argyll/ subtree structure survives into the bundle.
- Build phase rsyncs Vendor/Argyll/ → Contents/Resources/Argyll/.
- AppDelegate: killAll via terminateLater so children are signaled
  before teardown (#147/#149).
- 6 resolver tests + fetch script smoke-verified against real release.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 19:00:39 +01:00
gronod 20d6bf7fdc Merge pull request 'ProcessManager: spawn / stdin / kill / captured / event bus (#2)' (#34) from feat/2-process-manager into milestone/m1-foundation 2026-09-08 18:52:16 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5bb057a1e3 ProcessManager: spawn / stdin / kill / captured / event bus (#2)
- actor ProcessManager: runStreaming + runCaptured (concurrent pipe
  drain — no 64 KiB deadlock), sendStdin with independent stdin map
  (#84), kill/killAll, duplicate-id rejection (#116)
- Multicast AsyncStream<ProcessEvent> bus: stdout/stderr/exit/error/
  jsonRow (ROW_COLORS_JSON: prefix stripped)
- exit emitted exactly once, gated on both pipes reaching EOF so
  buffered output is never lost on fast exits or kills
- ARGYLL_NOT_INTERACTIVE=1 on every child; argv logged with ~ home
  sanitization; subprocess stdout→info, stderr→warn
- ProcessLineDecoder (byte-split at \n, UTF-8 safe, CRLF, unterminated
  tail flush), JSONAccumulator (multiline JSON for instlist/profcheck/
  manifest), ProcessID conventions
- 19 new tests incl. large-output captured run and stdin round-trip
- Fixup: entitlements gain com.apple.security.device.usb; sidebar
  preset/Calibrate disabled and only Stage 1 enabled per #1 AC

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 18:51:55 +01:00
gronod b998b48abf Merge pull request 'App scaffold & wizard shell (#1)' (#33) from feat/1-app-scaffold into milestone/m1-foundation 2026-09-08 18:30:28 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4440a26476 App scaffold & wizard shell (#1)
XcodeGen-managed project (project.yml), SwiftUI single-window app
1280x800 / min 1100x700, dark theme tokens from docs/21, 270pt sidebar
with logo/preset select/Calibrate/stepper 1-5, notice banner, five
stage placeholders, ICCeryCore SPM package with AppPaths + WizardStage,
Swift Testing plumbing. Sandbox off, hardened runtime on, bundle id
com.gronod.iccery2.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 18:29:50 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4c2863e52c Add bootstrap docs: AGENTS.md, BUILD-PLAN.md, LICENCE.md, vendored spec (docs/), brand assets
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 17:36:50 +01:00
106 changed files with 15822 additions and 0 deletions
+22
View File
@@ -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
+55
View File
@@ -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 0125). 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.
+48
View File
@@ -0,0 +1,48 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1M6 + 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 | 16 | App launches; wizard shell; ProcessManager + `runCaptured`; artefact gating tests; settings persist + dialog | N/A |
| M2 | Target generation & layout | 711 | targen → `.ti1`; printtarg → `.ti2`+TIFF; manifest+gallery; resume; presets | N/A |
| M3 | Unmanaged printing (`lp`) | 1215, 17 | parsers; `build_lp_args` goldens (both `AP_*`); cancel → nil | Preferences shows driver PDE; unmanaged page on Epson or Canon |
| M4 | Measurement | 1822 | `chartread.mock`; 39+ classifier fixtures; ΔE₀₀; snapshot/average | Detect real instrument; one strip or XY through Done → `.ti3` |
| M5 | Profile / verify / install | 2327 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1``.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 2832 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| 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
View File
@@ -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.
+23
View File
@@ -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
+13
View File
@@ -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,207 @@
import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable {
case processFailed(code: Int32, logs: [String])
case missingArtefact(String)
case malformedManifest(String)
public var errorDescription: String? {
switch self {
case .processFailed(let code, _):
return "Process exited with code \(code)"
case .missingArtefact(let path):
return "Expected output file was not created: \(path)"
case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)"
}
}
}
/// Result of a successful `printtarg` run: the `.ti2` artefact plus the
/// validated manifest with per-page PNG previews already decoded.
public struct PrinttargResult: Sendable, Equatable {
public let ti2URL: URL
public let manifest: PrinttargManifest
public let pages: [GalleryPage]
}
/// Service driving Argyll subprocesses off the main actor
/// (docs/03, docs/08, docs/09).
///
/// - Subscribes to the event bus *before* spawning so no stdout or exit
/// is ever lost (subscription is synchronous in `ProcessManager`).
/// - Accumulates stdout/stderr without touching `@MainActor`; the
/// optional `onLogBatch` callback receives coalesced chunks (20 lines
/// or ~100 ms), never one call per line.
/// - Exit code 0 is necessary but not sufficient: the expected artefact
/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a
/// valid `-u` manifest.
public struct ArgyllRunner: Sendable {
public let processManager: ProcessManager
public let binaryResolver: BinaryResolver
public init(
processManager: ProcessManager = .shared,
binaryResolver: BinaryResolver = BinaryResolver()
) {
self.processManager = processManager
self.binaryResolver = binaryResolver
}
// MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
/// upon completion. Returns the `.ti1` URL.
public func runTargen(
config: TargenConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
// MARK: - printtarg (Stage 2)
/// Runs `printtarg` streaming, then parses the `-u` manifest from
/// the complete accumulated stdout and loads each page's PNG
/// preview via `TiffPreview` (host-side, never raw TIFF to the UI).
public func runPrinttarg(
config: PrinttargConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> PrinttargResult {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
}
let manifest: PrinttargManifest
do {
manifest = try PrinttargManifestExtractor.manifest(from: run.stdout)
} catch {
throw ArgyllRunnerError.malformedManifest(error.localizedDescription)
}
let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in
let fileURL = cwd.appendingPathComponent(page.filename)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "File not found"
)
}
if let png = TiffPreview.previewPNG(tiff: fileURL) {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: png, previewError: nil
)
}
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "Could not decode TIFF"
)
}
return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages)
}
// MARK: - Shared collection
private struct CollectedRun {
var exitCode: Int32?
var stdout: String
var lines: [String]
}
/// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for
/// the manifest parse the pretty JSON needs its newlines).
private func collect(
id processId: String,
events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)?
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
var pendingBatch: [String] = []
var exitCode: Int32?
var lastFlush = Date()
func flush(_ batch: inout [String]) {
guard !batch.isEmpty else { return }
let out = batch
batch.removeAll(keepingCapacity: true)
onLogBatch?(out)
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
lines.append(line)
stdout += line + "\n"
pendingBatch.append(line)
case .stderr(_, let line):
lines.append(line)
pendingBatch.append(line)
case .error(_, let message):
lines.append("Error: \(message)")
pendingBatch.append("Error: \(message)")
case .jsonRow:
// Only chartread emits these; targen/printtarg never do.
break
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingBatch.count >= 20
|| Date().timeIntervalSince(lastFlush) >= 0.1 {
flush(&pendingBatch)
lastFlush = Date()
}
if exitCode != nil {
flush(&pendingBatch)
break
}
}
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
}
}
@@ -0,0 +1,100 @@
import Foundation
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
///
/// Order:
/// 1. Settings `argyll_binary_dir` override only if `<dir>/<name>`
/// exists there.
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
/// 3. If nothing exists the *constructed* bundled path is still returned
/// a missing binary surfaces later as `process:error` on spawn,
/// matching v1 semantics.
public struct BinaryResolver: Sendable {
/// Root that contains the platform dirs `Bundle.resource/Argyll` in
/// the app, a fixture dir in tests.
public let bundledRoot: URL
/// `settings.argyll_binary_dir`, already expanded to a URL.
public let overrideDir: URL?
/// Host architecture directory names, universal preferred.
public let archDirs: [String]
public init(
bundledRoot: URL = AppPaths.bundledArgyllDir,
overrideDir: URL? = nil,
archDirs: [String]? = nil
) {
self.bundledRoot = bundledRoot
self.overrideDir = overrideDir
#if arch(arm64)
let fallback = ["macos-arm64", "macos-aarch64"]
#else
let fallback = ["macos-x86_64"]
#endif
self.archDirs = archDirs ?? ["macos-universal"] + fallback
}
/// Marker used to decide whether `macos-universal` is usable.
public static let markerBinary = "instlist"
/// Resolves a tool name to an absolute URL (never throws see type
/// docs). `name` is the bare tool name, e.g. `"targen"`.
public func resolve(_ name: String) -> URL {
let fm = FileManager.default
if let dir = overrideDir {
let candidate = dir.appendingPathComponent(name)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return bundledRoot
.appendingPathComponent(platformDir(), isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// The bundled platform directory that resolution will use.
public func platformDir() -> String {
let fm = FileManager.default
let universal = bundledRoot.appendingPathComponent("macos-universal")
if fm.fileExists(
atPath: universal.appendingPathComponent(Self.markerBinary).path
) {
return "macos-universal"
}
for dir in archDirs where dir != "macos-universal" {
if fm.fileExists(
atPath: bundledRoot
.appendingPathComponent(dir)
.appendingPathComponent(Self.markerBinary).path
) {
return dir
}
}
// Nothing present still return the preferred dir so the error
// message points at where the user should drop binaries.
return archDirs.first ?? "macos-universal"
}
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
public func mock(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("mocks", isDirectory: true)
.appendingPathComponent("\(name).mock", isDirectory: false)
}
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// Whether the resolved path exists and is executable.
public func exists(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
}
@@ -0,0 +1,99 @@
import Foundation
/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2).
public enum PrinttargArgError: LocalizedError, Equatable {
case invalidCustomPageDimension(Double)
case invalidDPI(Int)
case invalidSeed(Int)
public var errorDescription: String? {
switch self {
case .invalidCustomPageDimension(let mm):
return "Custom page dimensions must be at least 50 mm, got: \(mm)"
case .invalidDPI(let dpi):
return "TIFF DPI must be between 72 and 600, got: \(dpi)"
case .invalidSeed(let seed):
return "Custom layout seed must be ≥ 1, got: \(seed)"
}
}
}
/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2).
///
/// Contract:
/// ```
/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
/// ```
public enum PrinttargArgs {
/// Builds the exact command-line arguments for `printtarg`.
///
/// Invariants:
/// - Always `-v -u` (the fork's `-u` emits the JSON page manifest).
/// - Default layout is deterministic `-R 1` (#163) a missing seed
/// reshuffles patches on every re-run and desyncs print vs `.ti2`.
/// - `.raster` emits `-r` and supersedes any seed. This is NOT
/// targen's `-r` full-spread algorithm (docs/25).
/// - `-d` is the chart **label** string, not colour space.
/// - `-K`/`-I` are never emitted for `CAL_` basenames the
/// calibration chart must not embed its own curves.
/// - Basename is the last positional argument.
public static func build(config: PrinttargConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = [
"-v", "-u",
"-i", config.instrument.rawValue,
"-p", try pageSizeValue(config),
]
switch config.layoutOrder {
case .deterministic:
args.append(contentsOf: ["-R", "1"])
case .customSeed:
guard config.customSeed >= 1 else {
throw PrinttargArgError.invalidSeed(config.customSeed)
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append("-r")
}
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
}
args.append(cleanBasename)
return args
}
private static func pageSizeValue(_ config: PrinttargConfig) throws -> String {
guard config.pageSize == .custom else { return config.pageSize.rawValue }
for dim in [config.customPageWidth, config.customPageHeight] {
guard dim >= 50 else {
throw PrinttargArgError.invalidCustomPageDimension(dim)
}
}
return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))"
}
/// Formats millimetres as an integer when exact, else decimal.
private static func formatMM(_ value: Double) -> String {
if value == value.rounded(), abs(value) < 1e15 {
return "\(Int(value))"
}
return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
}
}
@@ -0,0 +1,208 @@
import Foundation
/// Measurement instrument for `printtarg -i` chart geometry
/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes.
public enum PrintInstrument: String, Codable, Sendable, CaseIterable {
case i1
case p3
case cm = "CM"
case ss = "SS"
case dtp20 = "20"
case dtp22 = "22"
case dtp41 = "41"
case dtp51 = "51"
public var displayName: String {
switch self {
case .i1: return "X-Rite i1Pro / i1Pro 2"
case .p3: return "X-Rite i1Pro 3 / 3 Plus"
case .cm: return "ColorMunki"
case .ss: return "Specbos / Spectraval (XY table)"
case .dtp20: return "Gretag i1Display 2"
case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display"
case .dtp41: return "Datacolor Spyder 4/5"
case .dtp51: return "Spyder X"
}
}
}
/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm.
public enum PageSize: String, Codable, Sendable, CaseIterable {
case a4 = "A4"
case a4r = "A4R"
case a3 = "A3"
case a2 = "A2"
case letter = "Letter"
case letterR = "LetterR"
case legal = "Legal"
case fourBySix = "4x6"
case elevenBySeventeen = "11x17"
case custom = "custom"
public var isCustom: Bool { self == .custom }
}
/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit).
public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable {
case eight = 8
case sixteen = 16
public var flag: String {
switch self {
case .eight: return "-t"
case .sixteen: return "-T"
}
}
}
/// Patch layout order (docs/09 §Randomisation, #163).
/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and
/// supersedes any seed never confuse with targen's `-r` algorithm.
public enum LayoutOrder: String, Codable, Sendable, CaseIterable {
case deterministic
case customSeed = "custom_seed"
case raster
public var displayName: String {
switch self {
case .deterministic: return "Deterministic (seed 1)"
case .customSeed: return "Custom seed"
case .raster: return "Raster order (no shuffle)"
}
}
}
/// Chart label metadata used to assemble the automatic `printtarg -d`
/// label. Any empty/missing component becomes `Unspecified` until real
/// printer metadata lands in M3.
public struct TargetLabelMetadata: Codable, Equatable, Sendable {
public var printer: String
public var inkSet: String
public var driverPaper: String
public var actualPaper: String
public init(
printer: String = "",
inkSet: String = "",
driverPaper: String = "",
actualPaper: String = ""
) {
self.printer = printer
self.inkSet = inkSet
self.driverPaper = driverPaper
self.actualPaper = actualPaper
}
}
/// Builds the chart legend for `printtarg -d` (fork argyllcms#19,
/// ICCery #119). `-d` here is a **label string** not targen's colour
/// space, not iccgamut's density (docs/25).
public enum PrinttargLabel {
public static let unspecified = "Unspecified"
/// `ICCery - {basename} - {printer} - {ink} - {driverPaper} -
/// {actualPaper} - DD/MM/YYYY HH:MM`
///
/// `date` is injected for deterministic tests; production passes
/// the current local time. A fixed POSIX locale keeps the format
/// stable regardless of user locale.
public static func automatic(
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "dd/MM/yyyy HH:mm"
return [
"ICCery",
basename,
field(metadata.printer),
field(metadata.inkSet),
field(metadata.driverPaper),
field(metadata.actualPaper),
formatter.string(from: date),
].joined(separator: " - ")
}
/// Resolves the label to emit: an explicit non-empty manual label
/// wins; otherwise the assembled automatic label.
public static func resolved(
customLabel: String?,
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
return label
}
return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone)
}
private static func field(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? unspecified : trimmed
}
}
/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2).
public struct PrinttargConfig: Codable, Equatable, Sendable {
public var instrument: PrintInstrument
public var pageSize: PageSize
/// Custom page dimensions in millimetres; each must be 50 when
/// `pageSize == .custom`.
public var customPageWidth: Double
public var customPageHeight: Double
public var bitDepth: TiffBitDepth
public var dpi: Int
public var layoutOrder: LayoutOrder
/// Seed for `.customSeed` layout (`-R N`, N 1). Ignored for
/// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`).
public var customSeed: Int
/// Resolved `-d` label. Callers usually compute this via
/// `PrinttargLabel.resolved` so tests can inject the clock.
public var label: String?
/// `.cal` file applied to printed patches (`-K`), or embedded
/// without applying (`-I` when `calibrationEmbedOnly`). Never
/// emitted for `CAL_` basenames (Stage 0 protection).
public var calibrationFile: String?
public var calibrationEmbedOnly: Bool
public var basename: String
public var workingDirectory: URL?
public init(
instrument: PrintInstrument = .i1,
pageSize: PageSize = .a4,
customPageWidth: Double = 210,
customPageHeight: Double = 297,
bitDepth: TiffBitDepth = .eight,
dpi: Int = 300,
layoutOrder: LayoutOrder = .deterministic,
customSeed: Int = 1,
label: String? = nil,
calibrationFile: String? = nil,
calibrationEmbedOnly: Bool = false,
basename: String = "",
workingDirectory: URL? = nil
) {
self.instrument = instrument
self.pageSize = pageSize
self.customPageWidth = customPageWidth
self.customPageHeight = customPageHeight
self.bitDepth = bitDepth
self.dpi = dpi
self.layoutOrder = layoutOrder
self.customSeed = customSeed
self.label = label
self.calibrationFile = calibrationFile
self.calibrationEmbedOnly = calibrationEmbedOnly
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,180 @@
import Foundation
/// One page of a `printtarg -u` manifest (docs/05 §2.3).
/// `patches` is a page-assigned count including TID/padding cells,
/// not strictly user patches.
public struct PrinttargPage: Codable, Equatable, Sendable {
public var filename: String
public var patches: Int
public var widthMm: Double
public var heightMm: Double
public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) {
self.filename = filename
self.patches = patches
self.widthMm = widthMm
self.heightMm = heightMm
}
enum CodingKeys: String, CodingKey {
case filename, patches
case widthMm = "width_mm"
case heightMm = "height_mm"
}
}
/// The final-only, pretty-printed, **unprefixed** JSON object emitted
/// by fork `printtarg -u` after all pages are written (docs/05 §2.3).
public struct PrinttargManifest: Codable, Equatable, Sendable {
public var event: String
public var pages: [PrinttargPage]
public init(event: String = "manifest", pages: [PrinttargPage]) {
self.event = event
self.pages = pages
}
}
/// A manifest page resolved against the working directory, with its
/// host-side PNG preview (#58 TIFF is never fed to the UI directly).
public struct GalleryPage: Equatable, Sendable, Identifiable {
public var id: Int { index }
public let index: Int
public let page: PrinttargPage
public let fileURL: URL
public let previewPNG: Data?
public let previewError: String?
public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) {
self.index = index
self.page = page
self.fileURL = fileURL
self.previewPNG = previewPNG
self.previewError = previewError
}
}
public enum ManifestError: LocalizedError, Equatable {
case noJSONDocument
case wrongEvent(String)
case decodeFailed(String)
case invalidPage(String)
public var errorDescription: String? {
switch self {
case .noJSONDocument:
return "No JSON document found in printtarg stdout."
case .wrongEvent(let event):
return "Unexpected JSON event \"\(event)\" — expected \"manifest\"."
case .decodeFailed(let reason):
return "printtarg manifest JSON failed to decode: \(reason)"
case .invalidPage(let reason):
return "printtarg manifest page is invalid: \(reason)"
}
}
}
/// Extracts and decodes the `printtarg -u` manifest from the complete
/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest).
///
/// #68 invariant: the JSON is a structured document, not a brace-hunt.
/// Extraction is string/escape-aware a `{` or `}` inside a quoted
/// filename can never corrupt the scan and starts only at a `{` that
/// begins a trimmed stdout line.
public enum PrinttargManifestExtractor {
/// Finds the manifest object in accumulated stdout.
public static func manifest(from stdout: String) throws -> PrinttargManifest {
for block in jsonObjects(in: stdout) {
let data = Data(block.utf8)
guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else {
continue
}
guard manifest.event == "manifest" else {
throw ManifestError.wrongEvent(manifest.event)
}
try validate(manifest)
return manifest
}
if let first = jsonObjects(in: stdout).first,
let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any],
let event = obj["event"] as? String {
throw ManifestError.wrongEvent(event)
}
throw ManifestError.noJSONDocument
}
private static func validate(_ manifest: PrinttargManifest) throws {
for page in manifest.pages {
guard page.patches >= 0 else {
throw ManifestError.invalidPage("negative patch count \(page.patches)")
}
guard page.widthMm > 0, page.heightMm > 0 else {
throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)")
}
let name = page.filename
guard !name.isEmpty,
!name.hasPrefix("/"),
!name.contains("/"),
!name.contains("\\"),
!name.contains("..") else {
throw ManifestError.invalidPage("unsafe filename \"\(name)\"")
}
let ext = (name as NSString).pathExtension.lowercased()
guard ext == "tif" || ext == "tiff" else {
throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"")
}
}
}
/// Yields every complete top-level JSON object `{...}` found at a
/// trimmed line boundary, in document order. Depth tracking respects
/// quoted strings and backslash escapes.
static func jsonObjects(in text: String) -> [String] {
var out: [String] = []
let scalars = Array(text.unicodeScalars)
var i = 0
func isLineStart(_ idx: Int) -> Bool {
var j = idx - 1
while j >= 0 && scalars[j] != "\n" {
if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" {
return false
}
j -= 1
}
return true
}
while i < scalars.count {
if scalars[i] == "{", isLineStart(i) {
var depth = 0
var inString = false
var escaped = false
var j = i
while j < scalars.count {
let c = scalars[j]
if inString {
if escaped { escaped = false }
else if c == "\\" { escaped = true }
else if c == "\"" { inString = false }
} else {
if c == "\"" { inString = true }
else if c == "{" { depth += 1 }
else if c == "}" {
depth -= 1
if depth == 0 {
out.append(String(String.UnicodeScalarView(scalars[i...j])))
i = j
break
}
}
}
j += 1
}
}
i += 1
}
return out
}
}
@@ -0,0 +1,104 @@
import Foundation
/// Errors during `buildTargenArgs` validation (docs/04 §1.2).
public enum TargenArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPatchCount(Int)
case invalidWhitePatches(Int)
case invalidBlackPatches(Int)
case invalidInkLimit(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid target basename: \(name)"
case .invalidPatchCount(let count):
return "Patch count must be positive, got: \(count)"
case .invalidWhitePatches(let count):
return "White patches cannot be negative, got: \(count)"
case .invalidBlackPatches(let count):
return "Black patches cannot be negative, got: \(count)"
case .invalidInkLimit(let limit):
return "Ink limit must be between 1 and 400, got: \(limit)"
}
}
}
/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2).
public enum TargenArgs {
/// Builds the exact command-line arguments for `targen`.
///
/// Invariants:
/// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4).
/// - Never emits `-u` (Argyll fork progress is not enabled for targen).
/// - Always emits `-f N` when patchCount > 0 (#44).
/// - White `-e`, Black `-B`.
/// - `-N` omitted when approximately 0.50.
/// - `-A` is emitted even at 0.10 (no default-skip).
/// - `-l` is CMYK only (1...400).
/// - `-V` omitted when approximately 1.0.
/// - `-p` omitted when non-positive or approximately 1.0.
/// - Basename is the last positional argument.
public static func build(config: TargenConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.patchCount > 0 else {
throw TargenArgError.invalidPatchCount(config.patchCount)
}
guard config.whitePatches >= 0 else {
throw TargenArgError.invalidWhitePatches(config.whitePatches)
}
guard config.blackPatches >= 0 else {
throw TargenArgError.invalidBlackPatches(config.blackPatches)
}
var args: [String] = [
"-v",
"-d", config.colourSpace.dFlagValue,
"-f", "\(config.patchCount)",
"-e", "\(config.whitePatches)",
"-B", "\(config.blackPatches)"
]
if let g = config.greySteps, g > 0 {
args.append(contentsOf: ["-g", "\(g)"])
}
if let s = config.singleChannelSteps, s > 0 {
args.append(contentsOf: ["-s", "\(s)"])
}
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit {
guard (1...400).contains(inkLimit) else {
throw TargenArgError.invalidInkLimit(inkLimit)
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,150 @@
import Foundation
/// Colour space for patch generation (docs/08, docs/04 §1.2).
public enum ColourSpace: String, Codable, Sendable, CaseIterable {
case rgb
case cmyk
/// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK.
public var dFlagValue: String {
switch self {
case .rgb: return "2"
case .cmyk: return "4"
}
}
}
/// Patch count preset for Stage 1.
public enum PatchCountPreset: String, Codable, Sendable, CaseIterable {
case draft400 = "400"
case standard800 = "800"
case photo1500 = "1500"
case custom = "custom"
public var patchCount: Int? {
switch self {
case .draft400: return 400
case .standard800: return 800
case .photo1500: return 1500
case .custom: return nil
}
}
public var title: String {
switch self {
case .draft400: return "Draft (400)"
case .standard800: return "Standard (800)"
case .photo1500: return "Photo (1500)"
case .custom: return "Custom"
}
}
}
/// Full spread patch distribution algorithm (docs/08).
/// Default is "ofps" (no flag emitted).
public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable {
case ofps = "ofps"
case target = "-t"
case random = "-r"
case uniformRandom = "-R"
case quasiRandom = "-q"
case uniformQuasiRandom = "-Q"
case invertedQuasiRandom = "-i"
case invertedUniformQuasiRandom = "-I"
public var displayName: String {
switch self {
case .ofps: return "OFPS (Default)"
case .target: return "Target (-t)"
case .random: return "Random (-r)"
case .uniformRandom: return "Uniform Random (-R)"
case .quasiRandom: return "Quasi-random (-q)"
case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)"
case .invertedQuasiRandom: return "Inverted Quasi-random (-i)"
case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)"
}
}
public var flag: String? {
switch self {
case .ofps: return nil
default: return rawValue
}
}
/// Preset JSON value: `"ofps"` or the bare flag letter
/// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) docs/22.
public var presetValue: String {
switch self {
case .ofps: return "ofps"
default: return String(rawValue.dropFirst())
}
}
public init?(presetValue: String) {
if presetValue == "ofps" {
self = .ofps
} else {
self.init(rawValue: "-" + presetValue)
}
}
}
/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2).
public struct TargenConfig: Codable, Equatable, Sendable {
public var colourSpace: ColourSpace
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
public var fullSpreadAlgorithm: FullSpreadAlgorithm?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
public var basename: String
public var workingDirectory: URL?
public init(
colourSpace: ColourSpace = .rgb,
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: FullSpreadAlgorithm? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
basename: String = "",
workingDirectory: URL? = nil
) {
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Small host-side file helpers (issue #6).
public enum ArtefactFiles {
/// `get_default_working_dir` `resolveSafeCwd(nil)`.
public static func defaultWorkingDirectory() -> URL {
PathSecurity.resolveSafeCwd(nil)
}
/// `read_file_base64` for **text artefacts** the UI needs verbatim
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
/// go through `TiffPreview` instead.
public static func readBase64(_ url: URL) throws -> String {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version + build for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
}
}
@@ -0,0 +1,112 @@
import Foundation
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
public struct StageArtefacts: Sendable, Equatable {
/// `<basename>.ti1` exists (Stage 1 done unlocks Stage 2).
public var stage1Complete = false
/// `<basename>.ti2` exists (Stage 2 done with ti1, unlocks Stage 3).
public var stage2Complete = false
/// `<basename>.ti3` exists (Stage 3 done unlocks Stage 4).
public var stage3Complete = false
/// `.icc`/`.icm` exists (Stage 4 done with ti3, unlocks Stage 5).
public var stage4Complete = false
/// Absolute path of the profile file when present.
public var profilePath: URL?
public init(
stage1Complete: Bool = false,
stage2Complete: Bool = false,
stage3Complete: Bool = false,
stage4Complete: Bool = false,
profilePath: URL? = nil
) {
self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete
self.profilePath = profilePath
}
}
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
public enum ArtefactProbe {
/// `verify_stage_artefacts` the gating truth source.
public static func verify(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> StageArtefacts {
var out = StageArtefacts()
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true
out.profilePath = profile
}
return out
}
/// `<cwd>/<basename>.<ext>` the canonical artefact URL.
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
}
/// Profile extension resolution (#69): existing `.icm` wins over
/// `.icc`; when neither exists the macOS default is `.icc`.
/// (`profcheck`/`iccgamut` swap extension when the requested path is
/// missing.)
public static func resolveProfile(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> URL? {
let icm = artefact(basename, "icm", cwd)
if exists(icm, fm: fileManager) { return icm }
let icc = artefact(basename, "icc", cwd)
if exists(icc, fm: fileManager) { return icc }
return nil
}
/// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc"
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` and
/// `<basename>_NN.tif` (manifest naming).
public static func existingArtefacts(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefixes = [basename + ".", "CAL_" + basename + "."]
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
let passPrefix = basename + "_pass"
let tifStemPrefix = basename + "_"
let calPrefix = "CAL_" + basename
return entries.filter { url in
let name = url.lastPathComponent
let ext = url.pathExtension.lowercased()
guard suffixes.contains(ext) else { return false }
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
if name.hasPrefix(calPrefix) { return true }
return false
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
private static func exists(_ url: URL, fm: FileManager) -> Bool {
fm.fileExists(atPath: url.path)
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Atomic `.tmp`-then-rename file writes the convention used by
/// settings.json, verification_history.json and wizard_state.json
/// (docs/02 §Persistence, #213).
public enum AtomicFileWriter {
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
public static func write(_ data: Data, to url: URL) throws {
let fm = FileManager.default
let dir = url.deletingLastPathComponent()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let tmp = url.appendingPathExtension("tmp")
do {
try data.write(to: tmp, options: [])
// replaceItemAt handles same-volume atomic swap and removes
// the destination cleanly; fall back to remove+move.
if fm.fileExists(atPath: url.path) {
_ = try fm.replaceItemAt(url, withItemAt: tmp)
} else {
try fm.moveItem(at: tmp, to: url)
}
} catch {
try? fm.removeItem(at: tmp)
throw error
}
}
public static func write(_ text: String, to url: URL) throws {
try write(Data(text.utf8), to: url)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// Basename sanitisation and safe working-directory resolution
/// (docs/02 §Working directory, docs/06 §Empty cwd).
public enum PathSecurity {
public enum Error: Swift.Error, Equatable, Sendable {
case invalidBasename(String)
}
/// Basenames must not contain `/`, `\`, or `..` and must be
/// non-empty. Never invent a default basename (#60).
public static func isValidBasename(_ name: String) -> Bool {
guard !name.isEmpty else { return false }
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
}
@discardableResult
public static func sanitizeBasename(_ name: String) throws -> String {
guard isValidBasename(name) else {
throw Error.invalidBasename(name)
}
return name
}
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory
/// Documents Home app-data. Never returns an empty/nil cwd.
public static func resolveSafeCwd(
_ explicit: URL?,
fileManager: FileManager = .default
) -> URL {
if let explicit,
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
return explicit
}
let candidates: [URL?] = [
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
fileManager.homeDirectoryForCurrentUser,
AppPaths.appDataDir,
]
for candidate in candidates {
guard let url = candidate else { continue }
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
return url
}
}
// Last resort: app-data, created unconditionally.
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
return AppPaths.appDataDir
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
/// `parse_ti2_header` reads only CGATS keyword lines the data grid
/// itself belongs to issue #30.
public struct Ti2Header: Sendable, Equatable {
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
public var instrument: String?
/// `NUMBER_OF_SETS` the patch count. Note: `NUMBER_OF_FIELDS` is
/// the CGATS column count, *not* the patch count.
public var patchCount: Int?
/// `NUMBER_OF_PAGES`.
public var pageCount: Int?
/// A sibling `<stem>.ti1` exists next to the parsed file.
public var hasSiblingTi1 = false
public static func parse(
_ url: URL,
fileManager: FileManager = .default
) -> Ti2Header {
var header = Ti2Header()
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
return header
}
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
break
}
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
guard let space = line.firstIndex(of: " ") else { continue }
let key = String(line[..<space])
let value = String(line[line.index(after: space)...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "TARGET_INSTRUMENT":
header.instrument = value
case "NUMBER_OF_SETS":
header.patchCount = Int(value)
case "NUMBER_OF_PAGES":
header.pageCount = Int(value)
default:
continue
}
}
let stem = url.deletingPathExtension()
header.hasSiblingTi1 = fileManager.fileExists(
atPath: stem.appendingPathExtension("ti1").path
)
return header
}
}
@@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// TIFF PNG preview for the Stage 2 gallery (#58): decode on the host
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
/// bytes to the UI.
public enum TiffPreview {
public static let maxEdge: Int = 1200
/// Returns PNG data for the first page of a TIFF, or `nil` when the
/// file cannot be decoded.
public static func previewPNG(
tiff url: URL,
maxEdge: Int = Self.maxEdge
) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { return nil }
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { return nil }
return out as Data
}
}
@@ -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,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,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,17 @@
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)" }
}
@@ -0,0 +1,41 @@
import Foundation
/// Incremental byteline decoder for process pipes.
///
/// Splits raw `availableData` chunks at `0x0A`. A newline byte can never
/// appear inside a multi-byte UTF-8 sequence (continuation bytes are
/// 0x80), so splitting bytes at `\n` is always scalar-safe; each line
/// is then decoded with a lossy fallback for non-UTF-8 output.
public struct ProcessLineDecoder: Sendable {
public private(set) var pending = Data()
public init() {}
/// Feeds a chunk; returns every complete line found (without `\n`).
public mutating func feed(_ chunk: Data) -> [String] {
guard !chunk.isEmpty else { return [] }
pending.append(chunk)
var lines: [String] = []
while let nl = pending.firstIndex(of: 0x0A) {
var slice = pending.prefix(upTo: nl)
pending = pending.suffix(from: pending.index(after: nl))
// Tolerate CRLF output.
if slice.last == 0x0D { slice = slice.dropLast() }
lines.append(Self.decode(slice))
}
return lines
}
/// Flushes any unterminated remainder at EOF. Returns `nil` when empty.
public mutating func finish() -> String? {
guard !pending.isEmpty else { return nil }
var rest = pending
pending.removeAll(keepingCapacity: false)
if rest.last == 0x0D { rest = rest.dropLast() }
return rest.isEmpty ? nil : Self.decode(rest)
}
private static func decode(_ bytes: Data.SubSequence) -> String {
String(decoding: bytes, as: UTF8.self)
}
}
@@ -0,0 +1,371 @@
import Foundation
/// Captured output from `runCaptured` (used by printcal/applycal
/// the only tools whose results arrive as one-shot output).
public struct CapturedResult: Sendable, Equatable {
public let stdout: String
public let stderr: String
public let exitCode: Int32
}
/// Spawn / stdin / kill / event bus for Argyll sidecar children
/// (docs/02 §Event bus, docs/03 §Process manager).
///
/// Invariants:
/// - Duplicate `id` while a child runs is rejected (#116).
/// - The stdin handle lives in its own map, independent of wait, so
/// `sendStdin` never blocks on process exit (#84).
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
/// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills.
/// - `kill` drops the stdin handle so writers fail fast.
public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
public static let shared = ProcessManager()
// MARK: - Event bus (multicast)
/// Lock-protected subscriber table. Registration is *synchronous*
/// inside `events()` so a caller can subscribe, then spawn, without
/// racing the child's first output or exit event.
private final class SubscriberBox: @unchecked Sendable {
private let lock = NSLock()
private var map: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
func add(_ continuation: AsyncStream<ProcessEvent>.Continuation, token: UUID) {
lock.lock()
map[token] = continuation
lock.unlock()
}
func remove(_ token: UUID) {
lock.lock()
map.removeValue(forKey: token)
lock.unlock()
}
func yield(_ event: ProcessEvent) {
lock.lock()
let continuations = Array(map.values)
lock.unlock()
for continuation in continuations {
continuation.yield(event)
}
}
}
private nonisolated let subscriberBox = SubscriberBox()
/// Subscribe to the event bus. Each call returns an independent
/// stream; every event is delivered to every live subscriber.
/// The subscriber is registered before `events()` returns callers
/// may spawn immediately after subscribing without losing events.
public nonisolated func events() -> AsyncStream<ProcessEvent> {
let box = subscriberBox
let token = UUID()
return AsyncStream { continuation in
box.add(continuation, token: token)
continuation.onTermination = { _ in box.remove(token) }
}
}
private nonisolated func emit(_ event: ProcessEvent) {
subscriberBox.yield(event)
}
// MARK: - Child registry
private struct RunningChild {
let process: Process
/// stdin lives in its own slot, independent of process wait (#84).
var stdin: FileHandle?
var stdoutDecoder: ProcessLineDecoder
var stderrDecoder: ProcessLineDecoder
var stdoutEOF = false
var stderrEOF = false
/// Set by the termination handler; `exit` is emitted once both
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:]
/// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
public func isRunning(_ id: String) -> Bool {
children[id] != nil || captured[id] != nil
}
// MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for
/// `exit(id:)` events never assume the return means the tool
/// finished (docs/03).
public func runStreaming(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
children[id] = RunningChild(
process: process,
stdin: stdinPipe.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
do {
try process.run()
} catch {
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
}
stderrHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
process.terminationHandler = { [weak self] proc in
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
}
// MARK: - Spawn (captured)
/// Runs a child to completion and returns all output. Reads stdout
/// and stderr concurrently so a full pipe buffer can never deadlock
/// the child. Used by `printcal` / `applycal` (docs/03).
public func runCaptured(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register before run() so a concurrent duplicate spawn fails.
captured[id] = process
do {
try process.run()
} catch {
captured.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
async let errData = Task.detached {
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
}
}
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: code))
}
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
}
// MARK: - stdin
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
/// and flushes (docs/03 §stdin protocol).
public func sendStdin(id: String, bytes: Data) throws {
guard let child = children[id] else { throw ProcessError.unknownID(id) }
guard let handle = child.stdin else {
throw ProcessError.stdinFailed("stdin closed for \(id)")
}
do {
try handle.write(contentsOf: bytes)
} catch {
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
}
}
public func sendStdin(id: String, text: String) throws {
try sendStdin(id: id, bytes: Data(text.utf8))
}
// MARK: - Kill
/// Terminates a child. The `exit` event still fires exactly once.
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
public func kill(id: String) {
if var child = children[id] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: process.terminationStatus))
}
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() -> Int {
let ids = Array(children.keys) + Array(captured.keys)
for id in ids { kill(id: id) }
return ids.count
}
// MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1"
for (key, value) in extra { env[key] = value }
return env
}
private func ingestOutput(
_ data: Data,
id: String,
isStderr: Bool,
handle: FileHandle
) {
guard var child = children[id] else { return }
if data.isEmpty {
// EOF on this pipe.
handle.readabilityHandler = nil
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
children[id] = child
maybeFinalize(id: id)
return
}
let lines: [String] = isStderr
? child.stderrDecoder.feed(data)
: child.stdoutDecoder.feed(data)
children[id] = child
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
private func didTerminate(id: String, code: Int32) {
guard var child = children[id], !child.finalized else { return }
child.pendingExitCode = code
try? child.stdin?.close()
child.stdin = nil
children[id] = child
maybeFinalize(id: id)
}
/// Emits `exit` once the child has terminated *and* both pipes have
/// drained to EOF, so no buffered output is lost.
private func maybeFinalize(id: String) {
guard var child = children[id],
let code = child.pendingExitCode,
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
children.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
emit(.stderr(id: id, line: tail))
}
emit(.exit(id: id, code: code))
}
}
@@ -0,0 +1,251 @@
import Foundation
/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained
/// solely to decode and migrate pre-M2 `settings.json`; new code uses
/// `ProfilingPreset` (docs/22 §ProfilingPreset).
public struct CustomPreset: Codable, Equatable, Sendable {
public var name: String
/// Opaque per-stage form values keyed by field id.
public var values: [String: String]
public init(name: String, values: [String: String] = [:]) {
self.name = name
self.values = values
}
}
/// Where `install_profile` drops finished profiles (docs/22).
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
case user
case system
}
/// `settings.json` model (docs/22). snake_case keys match the v1 file
/// so field names stay identical across rewrites.
///
/// Decoding is tolerant: missing keys take documented defaults and each
/// `custom_presets` element is tried as a typed `ProfilingPreset` first
/// and as a legacy M1 `CustomPreset` second a malformed entry never
/// drops the rest of the array (preset migration, issue #11).
public struct AppSettings: Codable, Equatable, Sendable {
/// User override for Argyll binaries; `nil` bundled sidecars.
public var argyllBinaryDir: String?
/// Stored but **never applied to argv** Stage 2's own instrument
/// select is the live source (docs/04 §0.1).
public var defaultInstrument: String?
/// `nil` `.debug` in debug builds, `.info` in release (#158).
public var logLevel: LogLevel?
public var deltaEGoodMax: Double
public var deltaEWarningMax: Double
public var customPresets: [ProfilingPreset]
public var enableI1Pro2Leds: Bool
public var calibrationStaleDays: Int
public var defaultInstallLocation: InstallLocation
public var askBeforeOverwriteProfile: Bool
public var openColorPanelAfterInstall: Bool
public init(
argyllBinaryDir: String? = nil,
defaultInstrument: String? = nil,
logLevel: LogLevel? = nil,
deltaEGoodMax: Double = 2.0,
deltaEWarningMax: Double = 5.0,
customPresets: [ProfilingPreset] = [],
enableI1Pro2Leds: Bool = false,
calibrationStaleDays: Int = 30,
defaultInstallLocation: InstallLocation = .user,
askBeforeOverwriteProfile: Bool = true,
openColorPanelAfterInstall: Bool = false
) {
self.argyllBinaryDir = argyllBinaryDir
self.defaultInstrument = defaultInstrument
self.logLevel = logLevel
self.deltaEGoodMax = deltaEGoodMax
self.deltaEWarningMax = deltaEWarningMax
self.customPresets = customPresets
self.enableI1Pro2Leds = enableI1Pro2Leds
self.calibrationStaleDays = calibrationStaleDays
self.defaultInstallLocation = defaultInstallLocation
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
self.openColorPanelAfterInstall = openColorPanelAfterInstall
}
public static let `default` = AppSettings()
/// Effective log level runtime state, not just persistence (#158).
public var effectiveLogLevel: LogLevel {
if let logLevel { return logLevel }
#if DEBUG
return .debug
#else
return .info
#endif
}
enum CodingKeys: String, CodingKey {
case argyllBinaryDir = "argyll_binary_dir"
case defaultInstrument = "default_instrument"
case logLevel = "log_level"
case deltaEGoodMax = "delta_e_good_max"
case deltaEWarningMax = "delta_e_warning_max"
case customPresets = "custom_presets"
case enableI1Pro2Leds = "enable_i1pro2_leds"
case calibrationStaleDays = "calibration_stale_days"
case defaultInstallLocation = "default_install_location"
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
case openColorPanelAfterInstall = "open_color_panel_after_install"
}
/// One element of `custom_presets`: typed first, legacy M1 second.
private enum AnyPreset: Decodable {
case typed(ProfilingPreset)
case legacy(CustomPreset)
init(from decoder: Decoder) throws {
if let p = try? ProfilingPreset(from: decoder) {
self = .typed(p)
return
}
self = .legacy(try CustomPreset(from: decoder))
}
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings.default
argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir
defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument
logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel
deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax
deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax
enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds
calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays
defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation
askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile
openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall
// Per-element decode: typed presets win; a legacy M1 shape
// ({"name","values"}) migrates; unconvertible entries are
// skipped so one bad record never drops the array.
let elements = (try? c.decodeIfPresent(
[FailableDecodable<AnyPreset>].self, forKey: .customPresets
)) ?? nil
var migrated: [ProfilingPreset] = []
for (index, element) in (elements ?? []).enumerated() {
switch element.value {
case .typed(let preset):
migrated.append(preset)
case .legacy(let legacy):
if let converted = ProfilingPreset(migrating: legacy, index: index) {
migrated.append(converted)
} else {
AppLogger(category: "settings").warn(
"Skipped unmigratable legacy preset: \(legacy.name)"
)
}
case .none:
AppLogger(category: "settings").warn(
"Skipped malformed preset entry at index \(index)"
)
}
}
customPresets = migrated
}
/// UI-facing validation. Strings are part of the contract (issue #5).
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
public static let errorThresholdOrder =
"Good ΔE threshold must be strictly less than the warning threshold."
/// All validation errors, in declaration order. Empty = valid.
public func validate() -> [String] {
var errors: [String] = []
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
errors.append(Self.errorNegativeDeltaE)
}
if deltaEGoodMax >= deltaEWarningMax {
errors.append(Self.errorThresholdOrder)
}
return errors
}
public var isValid: Bool { validate().isEmpty }
}
extension ProfilingPreset {
/// Migrates a legacy M1 `CustomPreset` (`name` + string values) to
/// the typed schema. Known keys are coerced; anything else is
/// ignored. Returns `nil` only when the name is unusable a
/// deterministic `custom-{index}-{slug}` id is always produced.
init?(migrating legacy: CustomPreset, index: Int) {
let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return nil }
let v = legacy.values
func int(_ key: String) -> Int? {
v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}
func double(_ key: String) -> Double? {
v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) }
}
func bool(_ key: String) -> Bool? {
v[key].flatMap { s in
switch s.trimmingCharacters(in: .whitespaces).lowercased() {
case "true", "1", "yes": return true
case "false", "0", "no": return false
default: return nil
}
}
}
func string(_ key: String) -> String? {
v[key].map { $0.trimmingCharacters(in: .whitespaces) }
.flatMap { $0.isEmpty ? nil : $0 }
}
let slug = trimmedName.lowercased()
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
.reduce(into: "") { $0.append($1) }
self.init(
id: "custom-\(index)-\(slug)",
name: trimmedName,
description: string("description") ?? "",
colourSpace: string("colour_space")?.lowercased() ?? "rgb",
patchCount: int("patch_count") ?? 800,
whitePatches: int("white_patches") ?? 4,
blackPatches: int("black_patches") ?? 4,
greySteps: int("grey_steps"),
singleChannelSteps: int("single_channel_steps"),
neutralSteps: int("neutral_steps"),
neutralConcentration: double("neutral_concentration"),
preconditioningProfile: string("preconditioning_profile"),
ofpsHighQuality: bool("ofps_high_quality"),
ofpsAdaptation: double("ofps_adaptation"),
fullSpreadAlgorithm: string("full_spread_algorithm"),
totalInkLimit: int("total_ink_limit"),
darkEmphasis: double("dark_emphasis"),
devicePower: double("device_power"),
instrument: string("instrument") ?? "i1",
pageSize: string("page_size") ?? "A4",
bitDepth: int("bit_depth") ?? 8,
dpi: int("dpi") ?? 300,
randomSeed: int("random_seed"),
noRandomize: bool("no_randomize"),
calibrationFile: string("calibration_file"),
applyCalibration: bool("apply_calibration"),
colprofAlgorithm: string("colprof_algorithm"),
colprofQuality: string("colprof_quality"),
colprofIntent: string("colprof_intent"),
colprofFwa: string("colprof_fwa"),
colprofIlluminant: string("colprof_illuminant"),
colprofObserver: string("colprof_observer"),
colprofInputViewingCond: string("colprof_input_viewing_cond"),
colprofOutputViewingCond: string("colprof_output_viewing_cond")
)
}
}
@@ -0,0 +1,118 @@
import Foundation
/// Built-in presets shipped with the app (docs/22 §Built-in presets).
/// All four: instrument `i1`, FWA `D50`, random seed `1`,
/// `no_randomize == false`, colprof algorithm `l`.
///
/// Built-ins cannot be deleted; custom presets overlay by `id`.
public enum PresetCatalog {
/// `preset-std-rgb` Standard RGB Photo (800 patches).
public static let standardRGB = ProfilingPreset(
id: "preset-std-rgb",
name: "Standard RGB Photo (800 patches)",
description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.",
colourSpace: "rgb",
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "m",
colprofFwa: "D50"
)
/// `preset-hq-cmyk` High-Gamut CMYK Proofing (1500 patches).
public static let highQualityCMYK = ProfilingPreset(
id: "preset-hq-cmyk",
name: "High-Gamut CMYK Proofing (1500 patches)",
description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.",
colourSpace: "cmyk",
patchCount: 1500,
whitePatches: 4,
blackPatches: 8,
totalInkLimit: 320,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "h",
colprofFwa: "D50"
)
/// `preset-draft-rgb` Fast RGB Draft (400 patches, **150 dpi**).
public static let draftRGB = ProfilingPreset(
id: "preset-draft-rgb",
name: "Fast RGB Draft (400 patches)",
description: "Quick sanity check — 400 patches on A4 at 150 dpi.",
colourSpace: "rgb",
patchCount: 400,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 150,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "l",
colprofFwa: "D50"
)
/// `preset-ultra-rgb` Ultra Precision RGB (2500 patches, `-G`).
public static let ultraRGB = ProfilingPreset(
id: "preset-ultra-rgb",
name: "Ultra Precision RGB (2500 patches)",
description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.",
colourSpace: "rgb",
patchCount: 2500,
whitePatches: 6,
blackPatches: 6,
ofpsHighQuality: true,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "u",
colprofFwa: "D50"
)
public static let builtIns: [ProfilingPreset] = [
standardRGB, highQualityCMYK, draftRGB, ultraRGB,
]
public static let builtInIDs: Set<String> = Set(builtIns.map(\.id))
public static func isBuiltIn(_ id: String) -> Bool {
builtInIDs.contains(id)
}
/// Built-ins plus custom presets, with custom entries overlaying by
/// `id` (a custom preset with a built-in id replaces that entry in
/// place the built-in is still not deletable).
public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] {
var result = builtIns
var seen = builtInIDs
for custom in custom {
if let idx = result.firstIndex(where: { $0.id == custom.id }) {
result[idx] = custom
} else if !seen.contains(custom.id) {
result.append(custom)
seen.insert(custom.id)
}
}
return result
}
}
@@ -0,0 +1,98 @@
import Foundation
/// CRUD + import/export for profiling presets on top of `SettingsStore`
/// (docs/22 §Built-in presets, issue #11).
///
/// - `all()` = built-ins overlaid by custom presets (by `id`).
/// - Built-ins are never written to `settings.json` and cannot be
/// deleted or overwritten by `saveCustom` (a custom id that collides
/// with a built-in still overlays at read time, per spec).
/// - Import/export is single-preset JSON with schema validation.
/// - Imported names/descriptions are untrusted: callers must render
/// them with `Text`, never HTML (#114).
public final class PresetStore: Sendable {
public let settingsStore: SettingsStore
public init(settingsStore: SettingsStore = SettingsStore()) {
self.settingsStore = settingsStore
}
/// All presets: built-ins overlaid by customs, catalog order.
public func all() -> [ProfilingPreset] {
PresetCatalog.all(custom: settingsStore.load().customPresets)
}
/// Custom presets only, as persisted.
public func customs() -> [ProfilingPreset] {
settingsStore.load().customPresets
}
/// Insert or replace a custom preset (matched by `id`). Throws
/// `PresetStoreError.builtIn` when the id belongs to a built-in
/// built-ins are immutable. Validates before persisting.
public func saveCustom(_ preset: ProfilingPreset) throws {
let validated = try preset.validated()
guard !PresetCatalog.isBuiltIn(validated.id) else {
throw PresetStoreError.builtInImmutable(validated.id)
}
var settings = settingsStore.load()
if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) {
settings.customPresets[idx] = validated
} else {
settings.customPresets.append(validated)
}
try settingsStore.save(settings)
}
/// Deletes a custom preset by id. Returns false when the id is a
/// built-in (undeletable) or no custom preset with that id exists.
@discardableResult
public func deleteCustom(id: String) throws -> Bool {
guard !PresetCatalog.isBuiltIn(id) else { return false }
var settings = settingsStore.load()
let before = settings.customPresets.count
settings.customPresets.removeAll { $0.id == id }
guard settings.customPresets.count != before else { return false }
try settingsStore.save(settings)
return true
}
/// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
}
/// Parses + validates a preset from JSON. The preset is assigned a
/// fresh custom id when its id is empty or collides with a built-in.
/// Does **not** persist call `saveCustom` to keep it.
public func `import`(_ data: Data) throws -> ProfilingPreset {
let decoded: ProfilingPreset
do {
decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
} catch {
throw PresetStoreError.invalidJSON(error.localizedDescription)
}
var preset = try decoded.validated()
if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) {
preset.id = "custom-\(UUID().uuidString.lowercased())"
}
return preset
}
public enum PresetStoreError: LocalizedError, Equatable {
case builtInImmutable(String)
case invalidJSON(String)
public var errorDescription: String? {
switch self {
case .builtInImmutable(let id):
return "Built-in preset \"\(id)\" cannot be modified or deleted."
case .invalidJSON(let reason):
return "Not a valid preset file: \(reason)"
}
}
}
}
@@ -0,0 +1,272 @@
import Foundation
/// A profiling preset: a complete snapshot of the Stage 1/2 form plus
/// the Stage 4 fields that are stored now and applied in issue 23
/// (docs/22 §ProfilingPreset).
///
/// snake_case keys match the v1 JSON schema so import/export stays
/// compatible. Identity + Stage 1/2 core fields are required; every
/// other field is optional-defaulted. Unknown keys are ignored on
/// decode; missing required fields fail.
public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable {
// Identity
public var id: String
public var name: String
public var description: String
// Stage 1 (required core)
public var colourSpace: String // "rgb" | "cmyk"
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
// Stage 1 advanced (optional)
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
/// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I".
public var fullSpreadAlgorithm: String?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
// Stage 2 (required core)
public var instrument: String
public var pageSize: String
public var bitDepth: Int
public var dpi: Int
public var randomSeed: Int?
public var noRandomize: Bool?
// Stage 0 / 2 calibration
public var calibrationFile: String?
public var applyCalibration: Bool?
// Stage 4 (stored now, applied by issue 23)
public var colprofAlgorithm: String?
public var colprofQuality: String?
public var colprofIntent: String?
public var colprofFwa: String?
public var colprofIlluminant: String?
public var colprofObserver: String?
public var colprofInputViewingCond: String?
public var colprofOutputViewingCond: String?
public init(
id: String,
name: String,
description: String = "",
colourSpace: String = "rgb",
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: String? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
instrument: String = "i1",
pageSize: String = "A4",
bitDepth: Int = 8,
dpi: Int = 300,
randomSeed: Int? = 1,
noRandomize: Bool? = false,
calibrationFile: String? = nil,
applyCalibration: Bool? = nil,
colprofAlgorithm: String? = nil,
colprofQuality: String? = nil,
colprofIntent: String? = nil,
colprofFwa: String? = nil,
colprofIlluminant: String? = nil,
colprofObserver: String? = nil,
colprofInputViewingCond: String? = nil,
colprofOutputViewingCond: String? = nil
) {
self.id = id
self.name = name
self.description = description
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.instrument = instrument
self.pageSize = pageSize
self.bitDepth = bitDepth
self.dpi = dpi
self.randomSeed = randomSeed
self.noRandomize = noRandomize
self.calibrationFile = calibrationFile
self.applyCalibration = applyCalibration
self.colprofAlgorithm = colprofAlgorithm
self.colprofQuality = colprofQuality
self.colprofIntent = colprofIntent
self.colprofFwa = colprofFwa
self.colprofIlluminant = colprofIlluminant
self.colprofObserver = colprofObserver
self.colprofInputViewingCond = colprofInputViewingCond
self.colprofOutputViewingCond = colprofOutputViewingCond
}
enum CodingKeys: String, CodingKey {
case id, name, description
case colourSpace = "colour_space"
case patchCount = "patch_count"
case whitePatches = "white_patches"
case blackPatches = "black_patches"
case greySteps = "grey_steps"
case singleChannelSteps = "single_channel_steps"
case neutralSteps = "neutral_steps"
case neutralConcentration = "neutral_concentration"
case preconditioningProfile = "preconditioning_profile"
case ofpsHighQuality = "ofps_high_quality"
case ofpsAdaptation = "ofps_adaptation"
case fullSpreadAlgorithm = "full_spread_algorithm"
case totalInkLimit = "total_ink_limit"
case darkEmphasis = "dark_emphasis"
case devicePower = "device_power"
case instrument
case pageSize = "page_size"
case bitDepth = "bit_depth"
case dpi
case randomSeed = "random_seed"
case noRandomize = "no_randomize"
case calibrationFile = "calibration_file"
case applyCalibration = "apply_calibration"
case colprofAlgorithm = "colprof_algorithm"
case colprofQuality = "colprof_quality"
case colprofIntent = "colprof_intent"
case colprofFwa = "colprof_fwa"
case colprofIlluminant = "colprof_illuminant"
case colprofObserver = "colprof_observer"
case colprofInputViewingCond = "colprof_input_viewing_cond"
case colprofOutputViewingCond = "colprof_output_viewing_cond"
}
/// Strict decode: required identity + Stage 1/2 core fields must be
/// present; optionals default to nil. Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
patchCount = try c.decode(Int.self, forKey: .patchCount)
whitePatches = try c.decode(Int.self, forKey: .whitePatches)
blackPatches = try c.decode(Int.self, forKey: .blackPatches)
greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps)
singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps)
neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps)
neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration)
preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile)
ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality)
ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation)
fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm)
totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit)
darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis)
devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower)
instrument = try c.decode(String.self, forKey: .instrument)
pageSize = try c.decode(String.self, forKey: .pageSize)
bitDepth = try c.decode(Int.self, forKey: .bitDepth)
dpi = try c.decode(Int.self, forKey: .dpi)
randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed)
noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize)
calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration)
colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm)
colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality)
colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent)
colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa)
colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant)
colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver)
colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond)
colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond)
}
// MARK: - Validation (import path)
public enum ValidationError: LocalizedError, Equatable {
case emptyID
case emptyName
case invalidColourSpace(String)
case invalidPatchCount(Int)
case invalidBitDepth(Int)
case invalidDPI(Int)
case emptyPageSize
case emptyInstrument
public var errorDescription: String? {
switch self {
case .emptyID: return "Preset is missing an id."
case .emptyName: return "Preset is missing a name."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .invalidPatchCount(let v):
return "patch_count must be positive, got \(v)."
case .invalidBitDepth(let v):
return "bit_depth must be 8 or 16, got \(v)."
case .invalidDPI(let v):
return "dpi must be between 72 and 600, got \(v)."
case .emptyPageSize: return "page_size is empty."
case .emptyInstrument: return "instrument is empty."
}
}
}
/// Validates the required fields for import / catalog use.
/// `colourSpace` is normalized to lowercase before comparison.
@discardableResult
public func validated() throws -> ProfilingPreset {
var p = self
p.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
p.colourSpace = colourSpace.lowercased()
guard !p.id.isEmpty else { throw ValidationError.emptyID }
guard !p.name.isEmpty else { throw ValidationError.emptyName }
guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) }
guard p.bitDepth == 8 || p.bitDepth == 16 else {
throw ValidationError.invalidBitDepth(bitDepth)
}
guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) }
guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyPageSize
}
guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyInstrument
}
return p
}
}
/// Per-element non-throwing decode wrapper one malformed preset entry
/// must not drop the whole `custom_presets` array during migration.
struct FailableDecodable<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws {
value = try? T(from: decoder)
}
}
@@ -0,0 +1,47 @@
import Foundation
/// Persists `AppSettings` to
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
/// Posted on `NotificationCenter.default` after every successful save.
public static let settingsDidChange =
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
}
public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
}
/// Validates before persisting throws `SettingsError` listing
/// every violation; nothing is written on failure.
public func save(_ settings: AppSettings) throws {
let errors = settings.validate()
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
public enum SettingsError: Error, Equatable {
case validationFailed([String])
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
///
/// Navigation is *disk*, not buttons: a stage unlocks only when its
/// predecessor artefacts exist. Forward moves are gated; backward is
/// always allowed. Gating is re-evaluated on window focus and on stage
/// entry (#151 files can disappear in Finder).
public enum WizardGating {
/// Whether `stage` is reachable given the probed artefacts.
///
/// - Stage 0 (calibrate): always it is out-of-band, not gated.
/// - Stage 1: always.
/// - Stage 2: `.ti1` exists.
/// - Stage 3: `.ti1` **and** `.ti2`.
/// - Stage 4: `.ti3` exists (accepted measurement only a `.ti2`
/// alone never unlocks it; #109/#110).
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
public static func isUnlocked(
_ stage: WizardStage,
artefacts: StageArtefacts
) -> Bool {
switch stage {
case .calibrate: return true
case .generate: return true
case .layOutPrint: return artefacts.stage1Complete
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
case .buildProfile: return artefacts.stage3Complete
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
}
}
/// Whether `go(to:)` may proceed. Backward moves and the current
/// stage are always allowed; forward moves must be unlocked.
public static func canNavigate(
to target: WizardStage,
from current: WizardStage,
artefacts: StageArtefacts
) -> Bool {
if target == current { return true }
if target == .calibrate || current == .calibrate {
// Stage 0 is a side-trip, not stepper navigation.
return true
}
if target.rawValue < current.rawValue { return true }
return isUnlocked(target, artefacts: artefacts)
}
/// The deepest unlocked stepper stage used when revalidation
/// locks the current stage (#151).
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
for stage in WizardStage.stepperStages.reversed()
where isUnlocked(stage, artefacts: artefacts) {
return stage
}
return .generate
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The five wizard stages plus Stage 0 (calibration), matching the v1
/// `data-stage` contract in docs/21. Stepper buttons 15 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 (15); `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 {
/// 05 (`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)
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
# Mock script for chartread -u
# This script simulates the behaviour of chartread for testing purposes.
# Check for --xy argument or MOCK_XY_TABLE environment variable
IS_XY=0
for arg in "$@"; do
if [ "$arg" = "--xy" ]; then
IS_XY=1
break
fi
done
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read -r _calib
echo "Calibration successful."
echo "Please place sheet 1 of 1 on the table"
echo "hit return to continue, Esc or 'q' to give up"
read -r _sheet1
echo "locate patch A1 with the sight,"
echo "then hit return to continue"
read -r _fid1
echo "locate patch B24 with the sight,"
echo "then hit return to continue"
read -r _fid2
echo "Reading sheet 1..."
sleep 0.5
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expcted": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Sheet 1 of 1 read OK"
echo "Please remove last sheet from table"
exit 0
fi
# Handheld / strip reader simulation
echo "Place instrument on calibration tile and hit [Space] to calibrate."
# We don't really wait for input, just wait 1 second
sleep 1
echo "Calibration successful."
echo "Hit [Space] to read strip A (or 's' to skip)."
sleep 1
echo "Reading strip A..."
# Emit mock JSON for strip A
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expcted": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
EOF
echo "Hit [Space] to read strip B (or 's' to skip)."
sleep 1
echo "Reading strip B..."
# Emit mock JSON for strip B
cat << 'EOF'
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
EOF
echo "Ready to read... done."
exit 0
+20
View File
@@ -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
+12
View File
@@ -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
}
}
+6
View File
@@ -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

+10
View File
@@ -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>
+70
View File
@@ -0,0 +1,70 @@
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
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> AppEnvironment {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
settingsStore: settingsStore,
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
)
)
}
}
/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow
/// honours these env-provided paths instead of presenting modal panels
/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are
/// compiled out of release builds.
enum UITestHooks {
private static var env: [String: String] {
ProcessInfo.processInfo.environment
}
static var isEnabled: Bool {
#if DEBUG
return env["ICCERY_UI_TESTING"] == "1"
#else
return false
#endif
}
/// `select_target_file` result (Stage 1 save picker).
static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") }
/// `select_existing_target` result (`.ti1`/`.ti2` resume).
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
private static func url(_ key: String) -> URL? {
guard let raw = env[key], !raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw)
}
}
+129
View File
@@ -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
}
}
+52
View File
@@ -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
}
}
+63
View File
@@ -0,0 +1,63 @@
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")
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
)
}
}
+92
View File
@@ -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")
}
}
+85
View File
@@ -0,0 +1,85 @@
import AppKit
import SwiftUI
import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
private var model: WizardViewModel { workflow.wizard }
var body: some View {
HStack(spacing: 0) {
SidebarView(
workflow: workflow,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true }
)
Rectangle()
.fill(Theme.border)
.frame(width: 1)
VStack(spacing: 0) {
if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice)
}
stageContent
}
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
// #151: re-probe artefacts when the window regains focus
// files deleted in Finder must re-lock stages.
.onReceive(
NotificationCenter.default.publisher(
for: NSWindow.didBecomeKeyNotification
)
) { _ in model.windowDidBecomeKey() }
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $workflow.showingSavePreset) {
SavePresetDialog(workflow: workflow)
}
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {}
} message: {
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
}
}
@ViewBuilder
private var stageContent: some View {
switch model.stage {
case .generate:
Stage1View(workflow: workflow)
case .layOutPrint:
Stage2View(workflow: workflow)
case .measure:
// Stage 3 stays a shell until M4, but a .ti2 resume still
// lands here show the persisted state (#8, issue #140).
VStack(spacing: 16) {
if workflow.resumedFromTi2 {
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("stage3LoadedTargetBanner")
}
Text(model.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBasename")
StagePlaceholderView(stage: model.stage)
}
default:
StagePlaceholderView(stage: model.stage)
}
}
}
+169
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
private var model: WizardViewModel { workflow.wizard }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 40)
Spacer()
Button(action: onOpenSettings) {
Image(systemName: "gearshape")
}
.buttonStyle(.plain)
.help("Settings")
Button(action: onOpenAbout) {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.help("About ICCery")
}
.padding(12)
Divider().overlay(Theme.border)
// Preset select (`#presetSelect`) issue #11. Selection
// applies the preset immediately; names render via Text only.
Picker("Preset", selection: Binding(
get: { workflow.selectedPresetID },
set: { id in
if id == "none" {
workflow.selectedPresetID = "none"
} else if let preset = workflow.presets.first(where: { $0.id == id }) {
workflow.applyPreset(preset)
}
}
)) {
Text("No preset").tag("none")
ForEach(workflow.presets) { preset in
Text(preset.name).tag(preset.id)
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("presetSelect")
.padding(.horizontal, 12)
.padding(.vertical, 8)
HStack(spacing: 8) {
Button("Save") { workflow.showingSavePreset = true }
.accessibilityIdentifier("btnSavePresetModal")
Button("Manage") { workflow.showingManagePresets = true }
.accessibilityIdentifier("btnOpenPresetsDialog")
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
// Calibrate Printer (`#btnCalibratePrinter`). Disabled until
// Stage 0 lands in issue #29; `#calStatusChip` likewise.
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.disabled(true)
.padding(.horizontal, 12)
Divider().overlay(Theme.border)
.padding(.vertical, 8)
// Stepper 15.
VStack(alignment: .leading, spacing: 2) {
ForEach(WizardStage.stepperStages, id: \.self) { stage in
StepperRow(
stage: stage,
isActive: model.stage == stage,
// Artefact gating (issue #4) disk is truth.
isEnabled: model.isUnlocked(stage)
) {
model.go(to: stage)
}
}
}
.padding(.horizontal, 6)
Spacer()
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
}
private struct StepperRow: View {
let stage: WizardStage
let isActive: Bool
let isEnabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(isActive ? Theme.accent : Theme.border)
.frame(width: 26, height: 26)
Text("\(stage.stepperIndex ?? 0)")
.font(.callout.bold())
.foregroundStyle(isActive ? .white : Theme.text)
}
Label(stage.title, systemImage: stage.symbolName)
.font(.callout)
.foregroundStyle(isActive ? Theme.text : .secondary)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.45)
.background(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.fill(isActive ? Theme.accent.opacity(0.15) : .clear)
)
}
}
+256
View File
@@ -0,0 +1,256 @@
import SwiftUI
import ICCeryCore
/// Stage 1 `#stage-1` Generate Target (`targen` `.ti1`, issue #7,
/// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable.
struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
colourSpaceSection
patchSection
targetSection
advancedSection
actionRow
logSection
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.background)
.accessibilityIdentifier("stage-1")
}
// MARK: - Colour space (name="colourSpace")
private var colourSpaceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Colour space").font(.headline).foregroundStyle(Theme.text)
Picker("Colour space", selection: $workflow.colourSpace) {
Text("RGB (print drivers)").tag(ColourSpace.rgb)
Text("CMYK (RIP output)").tag(ColourSpace.cmyk)
}
.pickerStyle(.segmented)
.accessibilityIdentifier("colourSpace")
}
}
// MARK: - Patch count + white/black
private var patchSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Patches").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 16) {
Picker("Patch count", selection: $workflow.patchPreset) {
ForEach(PatchCountPreset.allCases, id: \.self) {
Text($0.title).tag($0)
}
}
.accessibilityIdentifier("patchCountPreset")
.frame(maxWidth: 220)
if workflow.patchPreset == .custom {
TextField("Patches", value: $workflow.customPatchCount, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 90)
.accessibilityIdentifier("patchCountCustom")
}
}
HStack(spacing: 16) {
Stepper(value: $workflow.whitePatches, in: 0...50) {
Text("White patches: \(workflow.whitePatches)")
}
.accessibilityIdentifier("whitePatches")
Stepper(value: $workflow.blackPatches, in: 0...50) {
Text("Black patches: \(workflow.blackPatches)")
}
.accessibilityIdentifier("blackPatches")
}
.foregroundStyle(Theme.text)
}
}
// MARK: - Target file / working directory
private var targetSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Target file").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 8) {
TextField("Basename (no extension)", text: $workflow.targetBasename)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetBasename")
Button("Browse…") { workflow.browseForTargetFile() }
.accessibilityIdentifier("btnBrowse")
Button("Working Dir…") { workflow.browseForWorkingDirectory() }
Button("Open Existing…") { workflow.openExistingTarget() }
.accessibilityIdentifier("btnOpenExisting")
Button("Import Dataset…") { /* CGATS import #94, later */ }
.accessibilityIdentifier("btn-import-dataset")
.disabled(true)
}
Text(workflow.targetDirectory?.path ?? "No working directory selected")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.accessibilityIdentifier("selectedPathDisplay")
}
}
// MARK: - Advanced (#targenAdvancedDetails)
/// UI tests pre-expand the group XCUI cannot reliably toggle a
/// macOS `DisclosureTriangle` (its click lands on the label).
@State private var advancedExpanded = UITestHooks.isEnabled
private var advancedSection: some View {
DisclosureGroup("Advanced", isExpanded: $advancedExpanded) {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 10) {
optionalInt("Grey steps (-g)",
enabled: $workflow.greyStepsEnabled,
value: $workflow.greySteps)
.accessibilityIdentifier("targenGreySteps")
optionalInt("Single-channel steps (-s)",
enabled: $workflow.singleChannelEnabled,
value: $workflow.singleChannelSteps)
.accessibilityIdentifier("targenSingleChannelSteps")
optionalInt("Neutral steps (-n)",
enabled: $workflow.neutralStepsEnabled,
value: $workflow.neutralSteps)
.accessibilityIdentifier("targenNeutralSteps")
optionalDouble("Neutral concentration (-N)",
enabled: $workflow.neutralConcEnabled,
value: $workflow.neutralConcentration,
range: 0.0...1.0)
.accessibilityIdentifier("targenNeutralConcentration")
optionalDouble("OFPS adaptation (-A)",
enabled: $workflow.adaptationEnabled,
value: $workflow.adaptation,
range: 0.0...1.0)
.accessibilityIdentifier("targenAdaptation")
}
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preconditioning profile",
text: Binding(
get: { workflow.preconditioningProfile ?? "" },
set: {
workflow.preconditioningProfile =
$0.isEmpty ? nil : $0
}))
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targenPrecondProfile")
Button("") { workflow.browseForPreconditioningProfile() }
.accessibilityIdentifier("btnBrowsePrecondProfile")
}
Toggle("OFPS high quality (-G)", isOn: $workflow.highQuality)
.accessibilityIdentifier("targenHighQuality")
Picker("Full-spread algorithm", selection: $workflow.algorithm) {
ForEach(FullSpreadAlgorithm.allCases, id: \.self) {
Text($0.displayName).tag($0)
}
}
.accessibilityIdentifier("targenAlgorithm")
if workflow.colourSpace == .cmyk {
optionalInt("Total ink limit (-l)",
enabled: $workflow.inkLimitEnabled,
value: $workflow.totalInkLimit)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenInkLimitGroup")
}
optionalDouble("Dark emphasis (-V)",
enabled: $workflow.darkEmphasisEnabled,
value: $workflow.darkEmphasis,
range: 0.0...3.0)
.accessibilityIdentifier("targenDarkEmphasis")
optionalDouble("Device power (-p)",
enabled: $workflow.devicePowerEnabled,
value: $workflow.devicePower,
range: 0.0...3.0)
.accessibilityIdentifier("targenDevicePower")
}
}
}
.foregroundStyle(Theme.text)
.padding(.top, 8)
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenAdvancedDetails")
}
private func optionalInt(
_ title: String,
enabled: Binding<Bool>,
value: Binding<Int>
) -> some View {
HStack {
Toggle(title, isOn: enabled)
.toggleStyle(.checkbox)
if enabled.wrappedValue {
TextField("", value: value, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
}
}
}
private func optionalDouble(
_ title: String,
enabled: Binding<Bool>,
value: Binding<Double>,
range: ClosedRange<Double>
) -> some View {
VStack(alignment: .leading) {
Toggle(title, isOn: enabled)
.toggleStyle(.checkbox)
if enabled.wrappedValue {
HStack {
Slider(value: value, in: range)
Text(value.wrappedValue, format: .number.precision(.fractionLength(2)))
.frame(width: 44)
.monospacedDigit()
}
}
}
}
// MARK: - Actions + log
private var actionRow: some View {
HStack {
Button(action: workflow.generateTarget) {
Label(workflow.targenRunning ? "Generating…" : "Generate Target",
systemImage: "square.grid.3x3")
}
.controlSize(.large)
.disabled(!workflow.canGenerate || workflow.targenRunning)
.accessibilityIdentifier("btnGenerate")
if workflow.targenRunning {
ProgressView().controlSize(.small)
}
Spacer()
}
}
private var logSection: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(workflow.targenLog.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: 120, maxHeight: 200)
.accessibilityIdentifier("targenLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("targenLogContainer")
}
}
+282
View File
@@ -0,0 +1,282 @@
import SwiftUI
import ICCeryCore
/// Stage 2 `#stage-2` Lay Out & Print (`printtarg` `.ti2` + TIFFs,
/// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3.
struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
cmWarning
formSection
labelSection
actionRow
logSection
gallerySection
printPanel
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.background)
.accessibilityIdentifier("stage-2")
}
// MARK: - Colour-management warning (#cmWarningBanner)
private var cmWarning: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Set your printer driver to “No Colour Adjustment” " +
"(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " +
"Any driver colour management corrupts the target.")
.font(.callout)
.foregroundStyle(Theme.text)
Spacer()
}
.padding(10)
.background(Color.orange.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.overlay(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.stroke(Color.orange.opacity(0.4))
)
.accessibilityIdentifier("cmWarningBanner")
}
// MARK: - Layout form
private var formSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
Picker("Instrument", selection: $workflow.instrument) {
ForEach(PrintInstrument.allCases, id: \.self) {
Text($0.displayName).tag($0)
}
}
.accessibilityIdentifier("instrumentSelect")
Picker("Page size", selection: $workflow.pageSize) {
ForEach(PageSize.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
.accessibilityIdentifier("pageSizeSelect")
}
if workflow.pageSize == .custom {
HStack(spacing: 8) {
Text("Custom size (mm):")
TextField("W", value: $workflow.customPageW, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 70)
.accessibilityIdentifier("customPageW")
Text("×")
TextField("H", value: $workflow.customPageH, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 70)
.accessibilityIdentifier("customPageH")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("customPageSizeRow")
}
HStack(spacing: 16) {
Picker("Bit depth", selection: $workflow.bitDepth) {
Text("8-bit TIFF").tag(TiffBitDepth.eight)
Text("16-bit TIFF").tag(TiffBitDepth.sixteen)
}
Stepper("DPI: \(workflow.tiffDpi)",
value: $workflow.tiffDpi, in: 72...600, step: 1)
.accessibilityIdentifier("tiffDpi")
}
HStack(spacing: 16) {
Picker("Layout order", selection: $workflow.layoutOrder) {
ForEach(LayoutOrder.allCases, id: \.self) {
Text($0.displayName).tag($0)
}
}
.accessibilityIdentifier("printtargLayoutOrder")
if workflow.layoutOrder == .customSeed {
HStack {
Text("Seed:")
TextField("", value: $workflow.customSeed, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 80)
.accessibilityIdentifier("printtargCustomSeed")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargCustomSeedGroup")
}
}
}
.foregroundStyle(Theme.text)
}
// MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview)
private var labelSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Chart label").font(.headline).foregroundStyle(Theme.text)
Spacer()
Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") {
workflow.labelIsCustom.toggle()
}
.accessibilityIdentifier("btnToggleLabelEdit")
}
HStack(spacing: 12) {
TextField("Printer", text: $workflow.metaPrinter)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataPrinter")
TextField("Ink set", text: $workflow.metaInkSet)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataInkSet")
}
HStack(spacing: 12) {
TextField("Driver paper", text: $workflow.metaDriverPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataDriverPaper")
TextField("Actual paper", text: $workflow.metaActualPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataActualPaper")
}
if workflow.labelIsCustom {
TextField("Custom label", text: $workflow.customLabel)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetLabelPreview")
} else {
Text(workflow.automaticLabel)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("targetLabelPreview")
}
}
}
// MARK: - Actions + log
private var actionRow: some View {
HStack {
Button(action: workflow.createLayout) {
Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout",
systemImage: "rectangle.grid.2x2")
}
.controlSize(.large)
.disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty)
.accessibilityIdentifier("btnCreateLayout")
if workflow.printtargRunning {
ProgressView().controlSize(.small)
}
Spacer()
}
}
private var logSection: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(workflow.printtargLog.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: 100, maxHeight: 180)
.accessibilityIdentifier("printtargLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargLogContainer")
}
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@ViewBuilder
private var gallerySection: some View {
if let result = workflow.printtargResult {
VStack(alignment: .leading, spacing: 8) {
Text("Target pages — \(result.manifest.pages.count) page(s), " +
"\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches")
.font(.headline).foregroundStyle(Theme.text)
.accessibilityIdentifier("galleryInfo")
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 220))],
spacing: 12
) {
ForEach(result.pages) { page in
GalleryPageView(page: page)
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("galleryGrid")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("tiffGallery")
}
}
// MARK: - Raw print panel (#rawPrintPanel) stubbed until M3
private var printPanel: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Print").font(.headline).foregroundStyle(Theme.text)
Text("Unmanaged printing (lp) lands in Milestone 3.")
.font(.caption).foregroundStyle(.secondary)
.accessibilityIdentifier("printNotification")
HStack(spacing: 8) {
Button("Print All") {}
.accessibilityIdentifier("btnPrintAll")
.disabled(true)
Button("Refresh Printers") {}
.accessibilityIdentifier("btnRefreshPrinters")
.disabled(true)
Button("Printer Properties") {}
.accessibilityIdentifier("btnPrinterProperties")
.disabled(true)
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
.accessibilityIdentifier("btnAdvanceToStage3")
.disabled(workflow.printtargResult == nil
|| !workflow.wizard.isUnlocked(.measure))
}
}
.padding(12)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
}
}
/// One gallery cell: PNG preview + per-page stubbed Print button.
private struct GalleryPageView: View {
let page: GalleryPage
var body: some View {
VStack(spacing: 6) {
if let png = page.previewPNG, let image = NSImage(data: png) {
Image(nsImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 240)
} else {
ZStack {
Rectangle().fill(Theme.panel).frame(height: 160)
Text(page.previewError ?? "No preview")
.font(.caption).foregroundStyle(.secondary)
}
}
Text(page.page.filename)
.font(.caption).foregroundStyle(Theme.text)
Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary)
Button("Print") {}
.disabled(true)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
}
.padding(8)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("galleryPage-\(page.index)")
}
}
+24
View File
@@ -0,0 +1,24 @@
import SwiftUI
import ICCeryCore
/// Placeholder stage surface for M1. Real stage UIs arrive in M2M5
/// (issues #7#31); Stage 0 lands in M6 (issue #29).
struct StagePlaceholderView: View {
let stage: WizardStage
var body: some View {
VStack(spacing: 16) {
Image(systemName: stage.symbolName)
.font(.system(size: 44))
.foregroundStyle(Theme.accent)
Text(stage.title)
.font(.title2)
.foregroundStyle(Theme.text)
Text("This stage is not implemented yet — see the milestone plan.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
}
}
@@ -0,0 +1,465 @@
import Foundation
import Observation
import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
/// application (issues #7#11).
///
/// `wizard` stays authoritative for persisted identity + disk gating;
/// this model owns the editable form, logs, gallery, and preset state.
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back.
@MainActor
@Observable
final class TargetWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb {
didSet {
guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4
}
}
var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500
var whitePatches = 4
var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false
var greySteps = 5
var singleChannelEnabled = false
var singleChannelSteps = 5
var neutralStepsEnabled = false
var neutralSteps = 3
var neutralConcEnabled = false
var neutralConcentration = 0.50
var preconditioningProfile: String?
var highQuality = false
var adaptationEnabled = false
var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false
var totalInkLimit = 320
var darkEmphasisEnabled = false
var darkEmphasis = 1.0
var devicePowerEnabled = false
var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4
var customPageW = 210.0
var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here).
var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1
var labelIsCustom = false
var customLabel = ""
var metaPrinter = ""
var metaInkSet = ""
var metaDriverPaper = ""
var metaActualPaper = ""
// MARK: - Run state
var targenRunning = false
var targenLog: [String] = []
var printtargRunning = false
var printtargLog: [String] = []
var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false
// MARK: - Presets
var presets: [ProfilingPreset] = []
var selectedPresetID = "none"
var showingSavePreset = false
var showingManagePresets = false
var savePresetName = ""
var savePresetDesc = ""
init(environment: AppEnvironment = .live()) {
self.environment = environment
self.wizard = WizardViewModel(stateStore: environment.stateStore)
reloadPresets()
}
// MARK: - Derived
var effectivePatchCount: Int {
patchPreset.patchCount ?? customPatchCount
}
var canGenerate: Bool {
PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil
}
var labelMetadata: TargetLabelMetadata {
TargetLabelMetadata(
printer: metaPrinter, inkSet: metaInkSet,
driverPaper: metaDriverPaper, actualPaper: metaActualPaper)
}
/// `#targetLabelPreview` live preview of the automatic label.
var automaticLabel: String {
PrinttargLabel.automatic(
basename: wizard.basename.isEmpty ? "target" : wizard.basename,
metadata: labelMetadata)
}
var selectedPreset: ProfilingPreset? {
presets.first { $0.id == selectedPresetID }
}
// MARK: - Stage 1: generate
func buildTargenConfig() -> TargenConfig {
TargenConfig(
colourSpace: colourSpace,
patchCount: effectivePatchCount,
whitePatches: whitePatches,
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm == .ofps ? nil : algorithm,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
basename: targetBasename,
workingDirectory: targetDirectory
)
}
func browseForTargetFile() {
let url = UITestHooks.isEnabled
? UITestHooks.saveTargetURL
: fileDialogs.selectTargetFile()
guard let url else { return }
targetBasename = url.deletingPathExtension().lastPathComponent
targetDirectory = url.deletingLastPathComponent()
}
func browseForWorkingDirectory() {
let url = UITestHooks.isEnabled
? UITestHooks.workDirURL
: fileDialogs.selectDirectory()
if let url { targetDirectory = url }
}
func browseForPreconditioningProfile() {
if let url = fileDialogs.selectProfileFile() {
preconditioningProfile = url.path
}
}
func generateTarget() {
guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false
let runner = environment.runner
Task {
do {
let url = try await runner.runTargen(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.targenLog.append(contentsOf: batch)
}
}
wizard.setTarget(
basename: config.basename,
workingDirectory: config.workingDirectory)
wizard.refreshGating()
wizard.showNotice("Target generated: \(url.lastPathComponent)")
wizard.go(to: .layOutPrint)
} catch {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
}
targenRunning = false
}
}
// MARK: - Issue 8: resume an existing target
/// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds.
func openExistingTarget() {
let url = UITestHooks.isEnabled
? UITestHooks.existingTargetURL
: fileDialogs.selectExistingTarget()
guard let url else { return }
let stem = url.deletingPathExtension().lastPathComponent
let dir = url.deletingLastPathComponent()
guard PathSecurity.isValidBasename(stem) else {
wizard.showNotice("Invalid target name.", kind: .error)
return
}
switch url.pathExtension.lowercased() {
case "ti1":
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = false
wizard.go(to: .layOutPrint)
case "ti2":
let header = Ti2Header.parse(url)
guard header.hasSiblingTi1 else {
wizard.showNotice(
"Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.",
kind: .error)
return
}
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = true
wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil)
wizard.go(to: .measure)
default:
wizard.showNotice(
"Not a target file — choose a .ti1 or .ti2.", kind: .error)
}
}
// MARK: - Stage 2: create layout
func buildPrinttargConfig() -> PrinttargConfig {
PrinttargConfig(
instrument: instrument,
pageSize: pageSize,
customPageWidth: customPageW,
customPageHeight: customPageH,
bitDepth: bitDepth,
dpi: tiffDpi,
layoutOrder: layoutOrder,
customSeed: customSeed,
label: PrinttargLabel.resolved(
customLabel: labelIsCustom ? customLabel : nil,
basename: wizard.basename,
metadata: labelMetadata),
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
}
func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil
let runner = environment.runner
Task {
do {
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.printtargLog.append(contentsOf: batch)
}
}
printtargResult = result
wizard.refreshGating()
wizard.showNotice(
"Layout created — \(result.manifest.pages.count) page(s) ready.")
} catch {
// Stay on Stage 2: non-zero exit, malformed manifest, or
// missing .ti2 must never advance the wizard (#156).
wizard.showNotice(
"printtarg failed: \(error.localizedDescription)", kind: .error)
}
printtargRunning = false
}
}
/// `#btnAdvanceToStage3` manual advance once `.ti2` exists.
func advanceToStage3() {
wizard.refreshGating()
wizard.go(to: .measure)
}
// MARK: - Presets
func reloadPresets() {
presets = environment.presetStore.all()
}
/// Applies every Stage 1/2 field of the preset to the live form
/// (bidirectional the draft preset's dpi=150 must be visible).
func applyPreset(_ preset: ProfilingPreset) {
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
customPatchCount = preset.patchCount
whitePatches = preset.whitePatches
blackPatches = preset.blackPatches
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
singleChannelSteps = preset.singleChannelSteps ?? 5
singleChannelEnabled = preset.singleChannelSteps != nil
neutralSteps = preset.neutralSteps ?? 3
neutralStepsEnabled = preset.neutralSteps != nil
neutralConcentration = preset.neutralConcentration ?? 0.50
neutralConcEnabled = preset.neutralConcentration != nil
preconditioningProfile = preset.preconditioningProfile
highQuality = preset.ofpsHighQuality == true
adaptation = preset.ofpsAdaptation ?? 0.10
adaptationEnabled = preset.ofpsAdaptation != nil
algorithm = preset.fullSpreadAlgorithm
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
totalInkLimit = preset.totalInkLimit ?? 320
inkLimitEnabled = preset.totalInkLimit != nil
darkEmphasis = preset.darkEmphasis ?? 1.0
darkEmphasisEnabled = preset.darkEmphasis != nil
devicePower = preset.devicePower ?? 1.0
devicePowerEnabled = preset.devicePower != nil
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
if let size = PageSize(rawValue: preset.pageSize) {
pageSize = size
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
pageSize = .custom; customPageW = w; customPageH = h
} else {
pageSize = .a4
}
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
tiffDpi = preset.dpi
if preset.noRandomize == true {
layoutOrder = .raster
} else if (preset.randomSeed ?? 1) == 1 {
layoutOrder = .deterministic
} else {
layoutOrder = .customSeed
}
customSeed = preset.randomSeed ?? 1
selectedPresetID = preset.id
}
/// Snapshot of the live Stage 1/2 form as a custom preset.
func saveCurrentAsPreset() {
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else {
wizard.showNotice("Preset needs a name.", kind: .warning)
return
}
let preset = ProfilingPreset(
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: effectivePatchCount,
whitePatches: whitePatches,
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm.presetValue,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
instrument: instrument.rawValue,
pageSize: pageSize == .custom
? "\(Int(customPageW))x\(Int(customPageH))"
: pageSize.rawValue,
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster
)
do {
try environment.presetStore.saveCustom(preset)
reloadPresets()
selectedPresetID = preset.id
showingSavePreset = false
savePresetName = ""
savePresetDesc = ""
wizard.showNotice("Preset saved: \(preset.name)")
} catch {
wizard.showNotice(
"Could not save preset: \(error.localizedDescription)", kind: .error)
}
}
func deletePreset(_ preset: ProfilingPreset) {
do {
if try environment.presetStore.deleteCustom(id: preset.id) {
if selectedPresetID == preset.id { selectedPresetID = "none" }
reloadPresets()
} else {
wizard.showNotice("Built-in presets cannot be deleted.", kind: .warning)
}
} catch {
wizard.showNotice(
"Could not delete preset: \(error.localizedDescription)", kind: .error)
}
}
func importPreset() {
let url = UITestHooks.isEnabled
? UITestHooks.presetImportURL
: fileDialogs.selectPresetFile()
guard let url else { return }
do {
let data = try Data(contentsOf: url)
let preset = try environment.presetStore.import(data)
try environment.presetStore.saveCustom(preset)
reloadPresets()
selectedPresetID = preset.id
wizard.showNotice("Preset imported: \(preset.name)")
} catch {
wizard.showNotice(
"Import failed: \(error.localizedDescription)", kind: .error)
}
}
func exportPreset(_ preset: ProfilingPreset) {
let url = UITestHooks.isEnabled
? UITestHooks.presetExportURL
: fileDialogs.selectPresetSavePath(name: preset.id)
guard let url else { return }
do {
try environment.presetStore.export(preset)
.write(to: url, options: .atomic)
wizard.showNotice("Preset exported: \(url.lastPathComponent)")
} catch {
wizard.showNotice(
"Export failed: \(error.localizedDescription)", kind: .error)
}
}
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
}
}
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
/// Design tokens carried over from the v1 stylesheet (docs/21 §Design tokens).
enum Theme {
static let background = Color(red: 0x1e / 255, green: 0x1e / 255, blue: 0x1e / 255)
static let panel = Color(red: 0x25 / 255, green: 0x25 / 255, blue: 0x26 / 255)
static let text = Color(red: 0xd4 / 255, green: 0xd4 / 255, blue: 0xd4 / 255)
static let accent = Color(red: 0x00 / 255, green: 0x7a / 255, blue: 0xcc / 255)
static let border = Color(red: 0x33 / 255, green: 0x33 / 255, blue: 0x33 / 255)
/// v1 window/titlebar backing colour (docs/02 §Window contract).
static let windowChrome = Color(red: 0x1a / 255, green: 0x1a / 255, blue: 0x22 / 255)
enum Metrics {
static let sidebarWidth: CGFloat = 270
static let buttonSmall: CGFloat = 28
static let buttonMedium: CGFloat = 36
static let buttonLarge: CGFloat = 40
static let cornerSmall: CGFloat = 4
static let cornerMedium: CGFloat = 6
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
import Observation
import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06).
///
/// `wizardState` fields (`currentStage`, `basename`, `cwd`,
/// `printerName`, `sessionMode`, `profileBasename`) are persisted to
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons.
@MainActor
@Observable
final class WizardViewModel {
// MARK: - wizardState fields (persisted)
var stage: WizardStage {
didSet { if stage != oldValue { persist() } }
}
/// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } }
}
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
}
var printerName: String? {
didSet { if printerName != oldValue { persist() } }
}
var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } }
}
/// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } }
}
// MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts()
private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>?
init(stateStore: WizardStateStore = WizardStateStore()) {
self.stateStore = stateStore
let s = stateStore.load()
self.stage = s.stage
self.basename = s.basename
self.workingDirectory = s.cwd.isEmpty ? nil : URL(fileURLWithPath: s.cwd)
self.printerName = s.printerName
self.sessionMode = s.sessionMode
self.profileBasename = s.profileBasename
refreshGating()
// A restored stage may have been locked since (#151).
if !WizardGating.isUnlocked(stage, artefacts: artefacts), stage != .calibrate {
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
}
}
// MARK: - Gating
/// `isUnlocked` for the sidebar stepper.
func isUnlocked(_ stage: WizardStage) -> Bool {
WizardGating.isUnlocked(stage, artefacts: artefacts)
}
/// `true` while Stage 0 (printer calibration) is shown.
var isCalibrating: Bool { stage == .calibrate }
/// Re-probes the artefact directory and re-locks (#151). Called on
/// window focus, stage entry, and basename/cwd changes.
func refreshGating() {
guard !basename.isEmpty, let dir = effectiveWorkingDirectory else {
artefacts = StageArtefacts()
return
}
artefacts = ArtefactProbe.verify(basename: basename, cwd: dir)
}
/// `setTarget(basename, cwd)` validates the basename (no `/`, `\`,
/// `..`; no placeholders #60) and resolves the cwd (#59).
func setTarget(basename: String, workingDirectory: URL?) {
do {
self.basename = try PathSecurity.sanitizeBasename(basename)
} catch {
showNotice("Invalid target name.", kind: .error)
return
}
self.workingDirectory = PathSecurity.resolveSafeCwd(workingDirectory)
}
/// cwd never stays empty once a basename exists (#59).
var effectiveWorkingDirectory: URL? {
if let workingDirectory { return workingDirectory }
return basename.isEmpty ? nil : PathSecurity.resolveSafeCwd(nil)
}
// MARK: - Navigation
/// `navigateToStage(n)` refuses locked forward moves with a
/// warning banner; backward is always allowed (docs/06).
func go(to target: WizardStage) {
guard target != .calibrate else { enterCalibration(); return }
if WizardGating.canNavigate(to: target, from: stage, artefacts: artefacts) {
stage = target
} else {
showNotice(
"Stage \(target.stepperIndex ?? 0) is locked — the required artefact is missing.",
kind: .warning
)
}
}
func enterCalibration() {
sessionMode = .calibration
stage = .calibrate
}
func exitCalibration() {
sessionMode = .profile
stage = .generate
}
/// Window-focus hook (#151): files deleted in Finder re-lock stages.
/// If the current stage re-locked, fall back to the deepest unlocked.
func windowDidBecomeKey() {
refreshGating()
if stage != .calibrate,
!WizardGating.isUnlocked(stage, artefacts: artefacts) {
stage = WizardGating.deepestUnlocked(artefacts: artefacts)
}
}
// MARK: - Notice
func showNotice(_ text: String, kind: Notice.Kind = .info, autoHideAfter: TimeInterval? = 6) {
noticeDismissTask?.cancel()
let notice = Notice(kind: kind, text: text, autoHideAfter: autoHideAfter)
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
}
}
}
}
func dismissNotice() {
noticeDismissTask?.cancel()
notice = nil
}
// MARK: - Persistence
private func persist() {
let state = WizardState(
currentStage: stage.rawValue,
basename: basename,
cwd: workingDirectory?.path ?? "",
printerName: printerName,
sessionMode: sessionMode,
profileBasename: profileBasename
)
try? stateStore.save(state)
}
}
+27
View File
@@ -0,0 +1,27 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
}
}
@@ -0,0 +1,124 @@
import Testing
import Foundation
import ImageIO
import UniformTypeIdentifiers
@testable import ICCeryCore
private func tempURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-af-\(UUID().uuidString)")
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try """
CTI2
TARGET_INSTRUMENT "i1iO"
NUMBER_OF_FIELDS 9
NUMBER_OF_SETS 800
NUMBER_OF_PAGES 3
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R
END_DATA_FORMAT
""".write(to: dir.appendingPathComponent("job.ti2"), atomically: true, encoding: .utf8)
try "CGATS".write(
to: dir.appendingPathComponent("job.ti1"), atomically: true, encoding: .utf8
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
@Test func numberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
ctx.setFillColor(CGColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
let image = ctx.makeImage()!
guard let dest = CGImageDestinationCreateWithURL(
url as CFURL, UTType.tiff.identifier as CFString, 1, nil
) else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { throw CocoaError(.fileWriteUnknown) }
return url
}
@Test func producesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
// PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
}
@Test func nonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-resolver-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try body(root)
return root
}
private func touch(_ url: URL, executable: Bool = true) throws {
FileManager.default.createFile(atPath: url.path, contents: Data())
if executable {
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path
)
}
}
@Test func overrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
}
@Test func overrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
}
@Test func universalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
}
let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal")
}
@Test func fallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
try touch(d.appendingPathComponent("instlist"))
}
let r = BinaryResolver(
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
#expect(r.platformDir() == "macos-x86_64")
}
@Test func missingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
}
}
@@ -0,0 +1,129 @@
import Testing
import Foundation
@testable import ICCeryCore
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-files-\(name)")
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
}
}
}
@Test func acceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good))
}
}
@Test func resolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
}
@Test func createsParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
@Test func icmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm")
}
@Test func enumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam",
"CAL_t.ti1", "CAL_t.cal",
// must NOT match:
"other.ti1", "t.txt", "CAL_other.ti1",
] { try touch(dir.appendingPathComponent(name)) }
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
.map(\.lastPathComponent)
for expected in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
#expect(names.contains(expected), "missing \(expected)")
}
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
+232
View File
@@ -0,0 +1,232 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("ProfilingPreset")
struct ProfilingPresetTests {
@Test("snake_case keys round-trip through Codable")
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p)
// Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk")
#expect(obj["patch_count"] as? Int == 1500)
#expect(obj["total_ink_limit"] as? Int == 320)
#expect(obj["bit_depth"] as? Int == 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2")
}
@Test("Unknown keys ignored; missing required field fails")
func schemaTolerance() throws {
let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x")
let missing = """
{"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)!
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(ProfilingPreset.self, from: missing)
}
}
@Test("Validation rejects bad colour space / dpi / bit depth")
func validation() {
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
}
}
@Suite("PresetCatalog")
struct PresetCatalogTests {
@Test("Four built-ins with the documented values")
func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]!
#expect(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]!
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]!
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]!
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1")
#expect(p.colprofFwa == "D50")
#expect(p.randomSeed == 1)
#expect(p.noRandomize == false)
#expect(p.colprofAlgorithm == "l")
}
}
@Test("Custom presets overlay by id; built-ins are not deletable")
func overlay() {
let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1"))
}
}
@Suite("PresetStore")
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("settings.json")
}
@Test("CRUD + export/import round-trip")
func crud() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.all().count == 5)
p.name = "Renamed"
try store.saveCustom(p)
#expect(store.customs().count == 1)
#expect(store.customs()[0].name == "Renamed")
let data = try store.export(p)
let imported = try store.import(data)
#expect(imported.name == "Renamed")
#expect(imported.dpi == 150)
#expect(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb"))
}
@Test("Import rewrites a built-in id to a fresh custom id")
func importBuiltinCollision() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id))
}
@Test("Built-ins are immutable through saveCustom")
func builtInImmutable() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) {
try store.saveCustom(shadowed)
}
}
}
@Suite("AppSettings preset migration")
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("settings.json")
}
@Test("Legacy M1 custom_presets migrate to typed schema")
func legacyMigration() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """
{"custom_presets":[
{"name":"Old One","values":{"colour_space":"cmyk","patch_count":"900",
"dpi":"150","bit_depth":"16","instrument":"p3","page_size":"A3"}},
{"name":"","values":{}},
42
]}
""".data(using: .utf8)!
try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1)
let p = settings.customPresets[0]
#expect(p.name == "Old One")
#expect(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk")
#expect(p.patchCount == 900)
#expect(p.dpi == 150)
#expect(p.bitDepth == 16)
#expect(p.instrument == "p3")
#expect(p.pageSize == "A3")
}
@Test("Typed presets load and re-save as the typed schema")
func typedRoundTrip() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url)
var s = AppSettings()
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s)
let loaded = store.load()
#expect(loaded.customPresets.first?.patchCount == 700)
}
@Test("Draft preset dpi=150 survives Codable + settings round-trip")
func draftDPI() throws {
let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["dpi"] as? Int == 150)
let back = try store.import(data)
#expect(back.dpi == 150)
}
}
+429
View File
@@ -0,0 +1,429 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("PrinttargArgs")
struct PrinttargArgsTests {
private func config(
instrument: PrintInstrument = .i1,
pageSize: PageSize = .a4,
customW: Double = 210, customH: Double = 297,
bitDepth: TiffBitDepth = .eight,
dpi: Int = 300,
layout: LayoutOrder = .deterministic,
seed: Int = 1,
label: String? = nil,
calFile: String? = nil,
calEmbed: Bool = false,
basename: String = "target"
) -> PrinttargConfig {
PrinttargConfig(
instrument: instrument, pageSize: pageSize,
customPageWidth: customW, customPageHeight: customH,
bitDepth: bitDepth, dpi: dpi,
layoutOrder: layout, customSeed: seed, label: label,
calibrationFile: calFile, calibrationEmbedOnly: calEmbed,
basename: basename
)
}
@Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300")
func baseline() throws {
let args = try PrinttargArgs.build(config: config())
#expect(args == ["-v", "-u", "-i", "i1", "-p", "A4",
"-R", "1", "-t", "300", "target"])
}
@Test("Default layout is deterministic -R 1, never bare")
func deterministicDefault() throws {
let args = try PrinttargArgs.build(config: config())
#expect(args.contains("-R"))
#expect(!args.contains("-r"))
#expect(args[args.firstIndex(of: "-R")! + 1] == "1")
}
@Test("Custom seed -R N; seed < 1 throws")
func customSeed() throws {
let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42))
#expect(args[args.firstIndex(of: "-R")! + 1] == "42")
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0))
}
}
@Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)")
func raster() throws {
let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9))
#expect(args.contains("-r"))
#expect(!args.contains("-R"))
}
@Test("Label: -d emits the resolved string, not a colour space")
func label() throws {
let args = try PrinttargArgs.build(
config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04"))
let i = args.firstIndex(of: "-d")!
#expect(args[i + 1].hasPrefix("ICCery - t"))
}
@Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600")
func bitDepthAndDPI() throws {
#expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600))
.contains("-T"))
#expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72))
.contains("-t"))
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 71))
}
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(dpi: 601))
}
}
@Test("All instruments emit their Argyll code")
func instruments() throws {
let expected: [(PrintInstrument, String)] = [
(.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"),
(.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"),
]
for (inst, code) in expected {
let args = try PrinttargArgs.build(config: config(instrument: inst))
#expect(args[args.firstIndex(of: "-i")! + 1] == code)
}
}
@Test("All fixed page sizes; custom emits WxH in mm")
func pageSizes() throws {
for size in PageSize.allCases where size != .custom {
let args = try PrinttargArgs.build(config: config(pageSize: size))
#expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue)
}
let custom = try PrinttargArgs.build(config: config(
pageSize: .custom, customW: 150, customH: 220))
#expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220")
}
@Test("Custom page below 50 mm throws")
func customPageTooSmall() {
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9))
}
#expect(throws: PrinttargArgError.self) {
try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10))
}
}
@Test("Calibration: -K applies, -I embeds")
func calibrationFlags() throws {
let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal"))
#expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal")
let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true))
#expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal")
#expect(!i.contains("-K"))
}
@Test("CAL_ basename never gets -K or -I")
func calProtection() throws {
let args = try PrinttargArgs.build(
config: config(calFile: "/tmp/a.cal", basename: "CAL_test"))
#expect(!args.contains("-K"))
#expect(!args.contains("-I"))
}
@Test("Unsafe basename throws")
func unsafeBasename() {
#expect(throws: PathSecurity.Error.self) {
try PrinttargArgs.build(config: config(basename: "../x"))
}
}
}
@Suite("PrinttargLabel")
struct PrinttargLabelTests {
private var fixedDate: Date {
var comps = DateComponents()
comps.year = 2026; comps.month = 2; comps.day = 3
comps.hour = 14; comps.minute = 5
return Calendar(identifier: .gregorian).date(from: comps)!
}
@Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM")
func automatic() {
let label = PrinttargLabel.automatic(
basename: "tgt",
metadata: TargetLabelMetadata(
printer: "Epson", inkSet: "CMYK",
driverPaper: "Photo", actualPaper: "Matte"),
date: fixedDate, timeZone: .current)
#expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - "))
#expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026"))
}
@Test("Missing metadata becomes Unspecified")
func unspecified() {
let label = PrinttargLabel.automatic(
basename: "tgt", metadata: TargetLabelMetadata(),
date: fixedDate, timeZone: .current)
#expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - "))
}
@Test("Manual label wins over automatic")
func manualWins() {
let resolved = PrinttargLabel.resolved(
customLabel: " My Label ", basename: "tgt",
metadata: TargetLabelMetadata(), date: fixedDate)
#expect(resolved == "My Label")
}
}
@Suite("PrinttargManifest")
struct PrinttargManifestTests {
private let prettySingle = """
Some log line
Doing work...
{
"event": "manifest",
"pages": [
{
"filename": "target.tif",
"patches": 800,
"width_mm": 210.0,
"height_mm": 297.0
}
]
}
trailing text
"""
private let prettyMulti = """
{
"event": "manifest",
"pages": [
{"filename": "p1.tif", "patches": 400, "width_mm": 210, "height_mm": 148},
{"filename": "p2.tif", "patches": 400, "width_mm": 210, "height_mm": 148}
]
}
"""
@Test("Decodes a single-page pretty manifest amid log noise")
func singlePage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettySingle)
#expect(m.event == "manifest")
#expect(m.pages.count == 1)
#expect(m.pages[0].filename == "target.tif")
#expect(m.pages[0].patches == 800)
}
@Test("Multi-page manifest preserves order")
func multiPage() throws {
let m = try PrinttargManifestExtractor.manifest(from: prettyMulti)
#expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"])
}
@Test("No JSON document → noJSONDocument")
func noJSON() {
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: "plain text\nno json")
}
}
@Test("Wrong event → wrongEvent")
func wrongEvent() {
let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n"
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
}
}
@Test("ROW_COLORS_JSON line is never treated as the manifest")
func rowColorsNotManifest() {
let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}"
// Extraction only starts at a '{' that begins a trimmed line,
// so the ROW_COLORS_JSON line is skipped entirely.
let m = try? PrinttargManifestExtractor.manifest(from: stdout)
#expect(m != nil)
#expect(m?.event == "manifest")
}
@Test("Braces inside a quoted filename do not corrupt the scan")
func bracesInFilename() throws {
let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n"
let m = try PrinttargManifestExtractor.manifest(from: stdout)
#expect(m.pages[0].filename == "a}b.tif")
}
@Test("Unsafe / non-TIFF filenames rejected")
func unsafeFilenames() {
for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] {
let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}"
#expect(throws: ManifestError.self) {
try PrinttargManifestExtractor.manifest(from: stdout)
}
}
}
}
@Suite("ArgyllRunner Printtarg")
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
return dir
}
/// A minimal valid TIFF (8-bit, tiny) for gallery preview tests.
private func writeTinyTIFF(at url: URL) throws {
// 1x1 8-bit grayscale TIFF, little-endian.
var bytes: [UInt8] = [
0x49, 0x49, 0x2A, 0x00, // II + magic
0x08, 0x00, 0x00, 0x00, // IFD offset
]
let ifdCount: UInt16 = 10
bytes += withUnsafeBytes(of: ifdCount.littleEndian) { Array($0) }
func tag(_ t: UInt16, _ type: UInt16, _ count: UInt32, _ value: UInt32) {
bytes += withUnsafeBytes(of: t.littleEndian) { Array($0) }
bytes += withUnsafeBytes(of: type.littleEndian) { Array($0) }
bytes += withUnsafeBytes(of: count.littleEndian) { Array($0) }
bytes += withUnsafeBytes(of: value.littleEndian) { Array($0) }
}
tag(256, 3, 1, 1) // ImageWidth = 1
tag(257, 3, 1, 1) // ImageLength = 1
tag(258, 3, 1, 8) // BitsPerSample = 8
tag(259, 3, 1, 1) // Compression = none
tag(262, 3, 1, 1) // Photometric = BlackIsZero
tag(273, 4, 1, 0) // StripOffsets patched below
tag(277, 3, 1, 1) // SamplesPerPixel = 1
tag(278, 3, 1, 1) // RowsPerStrip = 1
tag(279, 4, 1, 1) // StripByteCounts = 1
tag(284, 3, 1, 1) // PlanarConfig
bytes += [0, 0, 0, 0] // next IFD = none
let pixelOffset = bytes.count
bytes += [0x80] // the pixel
// Patch StripOffsets (located right after the tag header at
// offset 8 + 2 + 5*12 + 8 = position of value field).
let valuePos = 8 + 2 + 5 * 12 + 8
let off = UInt32(pixelOffset).littleEndian
withUnsafeBytes(of: off) { b in
bytes[valuePos] = b[0]; bytes[valuePos+1] = b[1]
bytes[valuePos+2] = b[2]; bytes[valuePos+3] = b[3]
}
try Data(bytes).write(to: url)
}
@Test("Successful printtarg emits .ti2 + manifest + PNG previews")
func success() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'log line\\n'
printf '{\\n "event": "manifest",\\n "pages": [\\n {"filename": "%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\\n ]\\n}\\n' "$last"
touch "$last.ti2"
exit 0
""")
defer { try? FileManager.default.removeItem(at: dir) }
// Basename "pt" manifest references pt.tif; write a real TIFF.
try writeTinyTIFF(at: dir.appendingPathComponent("pt.tif"))
let resolver = BinaryResolver(bundledRoot: dir, overrideDir: dir)
let runner = ArgyllRunner(
processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config)
#expect(result.ti2URL.lastPathComponent == "pt.ti2")
#expect(result.manifest.pages.count == 1)
#expect(result.pages.count == 1)
let png = result.pages[0].previewPNG
#expect(png != nil)
if let png {
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
}
}
@Test("Non-zero exit throws processFailed and stays on stage")
func failure() async throws {
let dir = try makeFixture("""
#!/bin/sh
echo "oops" >&2
exit 3
""")
defer { try? FileManager.default.removeItem(at: dir) }
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
@Test("Exit 0 without manifest → malformedManifest")
func noManifest() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
touch "$last.ti2"
echo "no json here"
exit 0
""")
defer { try? FileManager.default.removeItem(at: dir) }
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
@Test("Exit 0 without .ti2 → missingArtefact")
func noTi2() async throws {
let dir = try makeFixture("""
#!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
exit 0
""")
defer { try? FileManager.default.removeItem(at: dir) }
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir))
}
}
@Test("Deterministic config produces byte-identical .ti2")
func determinism() async throws {
let dir = try makeFixture("""
#!/bin/sh
last=""
for arg in "$@"; do last="$arg"; done
printf 'TI2\\nDETERMINISTIC\\n' > "$last.ti2"
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
exit 0
""")
defer { try? FileManager.default.removeItem(at: dir) }
let runner = ArgyllRunner(
processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
// Two runs, two basenames same argv except basename.
_ = try await runner.runPrinttarg(
config: PrinttargConfig(basename: "a", workingDirectory: dir))
_ = try await runner.runPrinttarg(
config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
#expect(d1 == d2)
}
}
@@ -0,0 +1,262 @@
import Testing
import Foundation
@testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized)
struct ProcessManagerTests {
// MARK: - Fixture plumbing
private static let fixtureDir: URL = {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
/// Writes a shell script fixture and returns its executable URL.
private func script(_ name: String, _ body: String) throws -> URL {
let url = Self.fixtureDir.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path
)
return url
}
/// Collects events for `id` until `.exit`, `timeout` seconds max.
private func collect(
_ manager: ProcessManager,
id: String,
timeout: TimeInterval = 10
) async -> [ProcessEvent] {
await withCheckedContinuation { cont in
let box = Box()
Task {
for await event in manager.events() {
guard event.id == id else { continue }
box.append(event)
if case .exit = event { break }
}
if box.finish() { cont.resume(returning: box.events) }
}
Task {
try? await Task.sleep(for: .seconds(timeout))
if box.finish() { cont.resume(returning: box.events) }
}
}
}
private final class Box: @unchecked Sendable {
private let lock = NSLock()
private var _events: [ProcessEvent] = []
private var finished = false
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
}
// MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1")
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
let evs = await events
let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil
}
#expect(lines == ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0)))
}
@Test func routesStderrSeparately() async throws {
let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops")))
}
@Test func stripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager()
let bin = try script(
"rows.sh",
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
)
async let evs = collect(pm, id: "t3")
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
let events = await evs
let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil
}
#expect(rows == ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout.
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
}
@Test func unterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
}
@Test func stdinRoundTrip() async throws {
let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern.
let bin = try script(
"echo.sh",
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
)
async let evs = collect(pm, id: "t5")
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
}
@Test func duplicateIDRejected() async throws {
let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await #expect(throws: ProcessError.duplicateID("t6")) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
}
await pm.kill(id: "t6")
}
@Test func killEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7")
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
await pm.kill(id: "t7")
let events = await evs
// exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1)
await #expect(throws: ProcessError.unknownID("t7")) {
try await pm.sendStdin(id: "t7", text: "d\n")
}
}
@Test func killAllCountsSignaled() async throws {
let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll()
#expect(count == 2)
}
@Test func capturedRunReturnsBothStreams() async throws {
let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data"))
#expect(result.exitCode == 3)
}
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script(
"big.sh",
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
)
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999"))
}
@Test func argyllEnvVarIsSet() async throws {
let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
}
@Test func unknownIDStdinThrows() async throws {
let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) {
try await pm.sendStdin(id: "nope", text: "d\n")
}
}
}
@Suite("ProcessLineDecoder")
struct ProcessLineDecoderTests {
@Test func splitsAcrossChunkBoundaries() {
var d = ProcessLineDecoder()
#expect(d.feed(Data("he".utf8)) == [])
#expect(d.feed(Data("llo\nwor".utf8)) == ["hello"])
#expect(d.feed(Data("ld\n".utf8)) == ["world"])
#expect(d.finish() == nil)
}
@Test func crlfIsStripped() {
var d = ProcessLineDecoder()
#expect(d.feed(Data("a\r\nb\r\n".utf8)) == ["a", "b"])
}
@Test func finishReturnsRemainder() {
var d = ProcessLineDecoder()
_ = d.feed(Data("x".utf8))
#expect(d.finish() == "x")
#expect(d.finish() == nil)
}
}
@Suite("JSONAccumulator")
struct JSONAccumulatorTests {
@Test func multilinePrettyJSON() {
var acc = JSONAccumulator()
#expect(acc.feed(line: "{") == nil)
#expect(acc.feed(line: " \"k\": 1") == nil)
let done = acc.feed(line: "}")
#expect(done != nil)
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
#expect(obj?["k"] == 1)
}
@Test func nonJSONLinesIgnored() {
var acc = JSONAccumulator()
#expect(acc.feed(line: "Reading instrument...") == nil)
#expect(acc.feed(line: "still text") == nil)
#expect(acc.completeData == nil)
}
@Test func decodeTyped() {
struct Doc: Decodable { let n: Int }
var acc = JSONAccumulator()
// Split so the doc completes on the second feed.
#expect(acc.feed(line: "{\"n\":") == nil)
let data = acc.feed(line: "7}")
#expect(data != nil)
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
#expect(doc?.n == 7)
#expect(acc.isEmpty)
}
}
@Suite("LogSanitizer")
struct LogSanitizerTests {
@Test func homeIsRewritten() {
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
#expect(LogSanitizer.sanitize(path) == "~/Documents/foo.ti1")
}
}
+164
View File
@@ -0,0 +1,164 @@
import Testing
import Foundation
@testable import ICCeryCore
private func tempStoreURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-settings-\(UUID().uuidString)")
.appendingPathComponent("settings.json")
}
@Suite("AppSettings")
struct AppSettingsTests {
@Test func defaults() {
let s = AppSettings.default
#expect(s.argyllBinaryDir == nil)
#expect(s.defaultInstrument == nil)
#expect(s.logLevel == nil)
#expect(s.deltaEGoodMax == 2.0)
#expect(s.deltaEWarningMax == 5.0)
#expect(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30)
#expect(s.defaultInstallLocation == .user)
#expect(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall)
#expect(s.isValid)
}
@Test func negativeThresholds() {
var s = AppSettings.default
s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [
AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder,
])
}
@Test func goodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default
s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9
#expect(s.isValid)
}
@Test func snakeCaseKeys() throws {
let s = AppSettings.default
let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\""))
}
}
@Suite("SettingsStore")
struct SettingsStoreTests {
@Test func roundTrip() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3"
try store.save(s)
#expect(store.load() == s)
}
@Test func corruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
#expect(SettingsStore(fileURL: url).load() == .default)
}
@Test func missingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default)
}
@Test func invalidSettingsNotPersisted() throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) }
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func savePostsNotification() async throws {
let url = tempStoreURL()
let store = SettingsStore(fileURL: url)
var fired = false
let token = NotificationCenter.default.addObserver(
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default)
#expect(fired)
}
}
@Suite("LogSink")
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)")
.appendingPathComponent("iccery.log")
return (url, LogSink(fileURL: url))
}
@Test func writesFormattedLines() {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(content.contains("[INFO] test: hello"))
}
@Test func levelFilteringIsLive() {
let (url, sink) = tempLog()
sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden"))
#expect(content.contains("shown"))
}
@Test func rotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog()
sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it.
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes))
try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path
))
// Active log is small again.
let size = try FileManager.default.attributesOfItem(
atPath: url.path
)[.size] as? UInt64
#expect((size ?? 0) < 1024)
}
@Test func tailExcerptCaps() throws {
let (url, sink) = tempLog()
sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8)
}
}
+330
View File
@@ -0,0 +1,330 @@
import Testing
import Foundation
@testable import ICCeryCore
@Suite("TargenArgs")
struct TargenArgsTests {
@Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4")
func rgbBaseline() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
basename: "test_rgb"
)
let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"])
#expect(!args.contains("-u"))
}
@Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0")
func cmykBaseline() throws {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 1500,
whitePatches: 4,
blackPatches: 0,
basename: "test_cmyk"
)
let args = try TargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"])
}
@Test("Custom patch count honours -f (#44)")
func customPatchCount() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 2500,
whitePatches: 4,
blackPatches: 4,
basename: "custom_patches"
)
let args = try TargenArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args[args.firstIndex(of: "-f")! + 1] == "2500")
}
@Test("All advanced flags in stable order")
func allAdvancedFlags() throws {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 1200,
whitePatches: 6,
blackPatches: 2,
greySteps: 12,
singleChannelSteps: 8,
neutralSteps: 6,
neutralConcentration: 0.75,
preconditioningProfile: "/path/to/profile.icc",
ofpsHighQuality: true,
ofpsAdaptation: 0.10,
fullSpreadAlgorithm: .target,
totalInkLimit: 320,
darkEmphasis: 1.50,
devicePower: 2.0,
basename: "advanced_cmyk"
)
let args = try TargenArgs.build(config: config)
let expected = [
"-v", "-d", "4",
"-f", "1200",
"-e", "6",
"-B", "2",
"-g", "12",
"-s", "8",
"-n", "6",
"-N", "0.75",
"-c", "/path/to/profile.icc",
"-G",
"-A", "0.10",
"-t",
"-l", "320",
"-V", "1.50",
"-p", "2.00",
"advanced_cmyk"
]
#expect(args == expected)
}
@Test("RGB ignores total ink limit")
func rgbIgnoresInkLimit() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
totalInkLimit: 300,
basename: "rgb_no_ink"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-l"))
}
@Test("Neutral concentration omitted when approximately 0.50")
func neutralConcentrationOmittedWhenDefault() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
neutralConcentration: 0.5005,
basename: "n_default"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-N"))
}
@Test("Adaptation emitted even at 0.10 (no default-skip)")
func adaptationEmittedAtPointOne() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
ofpsAdaptation: 0.10,
basename: "a_flag"
)
let args = try TargenArgs.build(config: config)
#expect(args.contains("-A"))
#expect(args[args.firstIndex(of: "-A")! + 1] == "0.10")
}
@Test("OFPS full spread algorithm emits no flag")
func ofpsEmitsNoFlag() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
fullSpreadAlgorithm: .ofps,
basename: "ofps_test"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("ofps"))
#expect(!args.contains("-t"))
}
@Test("Dark emphasis and device power omitted when 1.0")
func darkEmphasisAndPowerOmittedWhenOne() throws {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
darkEmphasis: 1.0,
devicePower: 1.0,
basename: "defaults_omitted"
)
let args = try TargenArgs.build(config: config)
#expect(!args.contains("-V"))
#expect(!args.contains("-p"))
}
@Test("Invalid basename throws")
func invalidBasenameThrows() {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
basename: "../bad_name"
)
#expect(throws: PathSecurity.Error.self) {
try TargenArgs.build(config: config)
}
}
@Test("Invalid patch count throws")
func invalidPatchCountThrows() {
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 0,
whitePatches: 4,
blackPatches: 4,
basename: "bad_count"
)
#expect(throws: TargenArgError.self) {
try TargenArgs.build(config: config)
}
}
@Test("Invalid ink limit throws for CMYK")
func invalidInkLimitThrows() {
let config = TargenConfig(
colourSpace: .cmyk,
patchCount: 800,
whitePatches: 4,
blackPatches: 0,
totalInkLimit: 450,
basename: "bad_ink"
)
#expect(throws: TargenArgError.self) {
try TargenArgs.build(config: config)
}
}
}
@Suite("ArgyllRunner Targen")
struct ArgyllRunnerTargenTests {
@Test("Successful targen execution creates .ti1 and returns URL")
func successfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
// Create a mock targen script
let mockScript = """
#!/bin/sh
# Find the last argument which is the basename
for arg do shift; set -- "$@" "$arg"; done
last="$arg"
echo "Generating patches..."
touch "$last.ti1"
echo "Done!"
exit 0
"""
let mockURL = tempDir.appendingPathComponent("targen")
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
let pm = ProcessManager()
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
basename: "mock_test",
workingDirectory: tempDir
)
var logLines: [String] = []
final class LogBox: @unchecked Sendable {
var lines: [String] = []
let lock = NSLock()
func append(_ batch: [String]) {
lock.lock(); lines.append(contentsOf: batch); lock.unlock()
}
}
let box = LogBox()
let ti1URL = try await runner.runTargen(config: config) { batch in
box.append(batch)
}
logLines = box.lines
#expect(logLines.contains("Generating patches..."))
#expect(FileManager.default.fileExists(atPath: ti1URL.path))
#expect(ti1URL.lastPathComponent == "mock_test.ti1")
}
@Test("Failed targen execution throws processFailed")
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let mockScript = """
#!/bin/sh
echo "Error: something went wrong" >&2
exit 1
"""
let mockURL = tempDir.appendingPathComponent("targen")
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
let pm = ProcessManager()
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
basename: "fail_test",
workingDirectory: tempDir
)
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
}
}
@Test("Targen exit 0 without .ti1 throws missingArtefact")
func missingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let mockScript = """
#!/bin/sh
echo "Exited 0 but did not create file"
exit 0
"""
let mockURL = tempDir.appendingPathComponent("targen")
try mockScript.write(to: mockURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path)
let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir)
let pm = ProcessManager()
let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver)
let config = TargenConfig(
colourSpace: .rgb,
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
basename: "no_file",
workingDirectory: tempDir
)
await #expect(throws: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
}
}
}
@@ -0,0 +1,127 @@
import Testing
import Foundation
@testable import ICCeryCore
private func artefacts(
ti1: Bool = false, ti2: Bool = false, ti3: Bool = false, profile: Bool = false
) -> StageArtefacts {
var a = StageArtefacts()
a.stage1Complete = ti1
a.stage2Complete = ti2
a.stage3Complete = ti3
a.stage4Complete = profile
if profile {
a.profilePath = URL(fileURLWithPath: "/x/t.icc")
}
return a
}
@Suite("WizardGating matrix")
struct WizardGatingTests {
@Test func emptyProjectOnlyStage1() {
let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
}
}
@Test func ti1UnlocksStage2Only() {
let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a))
}
@Test func stage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
}
@Test func stage4NeedsTi3NotTi2() {
// #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
}
@Test func stage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true)
))
}
@Test func forwardGatedBackwardFree() {
let a = artefacts()
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
// Backward always allowed even when artefacts vanished.
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
// Same stage is a no-op.
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
// Stage 0 is a side-trip, never gated.
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
}
@Test func deepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate)
#expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true)
) == .measure)
#expect(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall)
}
}
@Suite("WizardStateStore")
struct WizardStateStoreTests {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json")
}
@Test func roundTrip() throws {
let url = tempURL()
let store = WizardStateStore(fileURL: url)
var s = WizardState()
s.currentStage = 3
s.basename = "run-42"
s.cwd = "/tmp/charts"
s.sessionMode = .calibration
s.profileBasename = "imported"
try store.save(s)
#expect(store.load() == s)
}
@Test func missingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default)
#expect(s.stage == .generate)
#expect(s.sessionMode == .profile)
}
@Test func corruptStageFallsBackToGenerate() throws {
let url = tempURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
}
@Test func sessionModeCalibrationRoundTrips() throws {
var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration)
s.sessionMode = .profile
#expect(s.sessionMode == .profile)
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
last=""
for arg in "$@"; do last="$arg"; done
if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then
echo "mock printtarg failure" >&2
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
fi
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif"
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n'
touch "$last.ti2"
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Mock targen for Milestone2UITests. Emits a fake .ti1 next to the
# basename (last argv) in the process working directory. Exit code is
# overridable via ICCERY_MOCK_TARGEN_EXIT.
last=""
for arg in "$@"; do last="$arg"; done
echo "targen mock: generating $last"
if [ "${ICCERY_MOCK_TARGEN_EXIT:-0}" -ne 0 ]; then
echo "mock targen failure" >&2
exit "$ICCERY_MOCK_TARGEN_EXIT"
fi
touch "$last.ti1"
exit 0
+355
View File
@@ -0,0 +1,355 @@
import XCTest
/// Milestone 2 UI tests issues #7#11 (docs/21 element contract).
/// Every test launches the app with an isolated `ICCERY_TEST_ROOT`,
/// fixture sidecars via `ICCERY_ARGYLL_BINARY_DIR`, and
/// `ICCERY_UI_TESTING=1` so file dialogs resolve to env-provided
/// paths instead of modal panels. No hardware, no network, no real
/// Argyll install, and nothing is written to the developer's app data.
@MainActor
final class Milestone2UITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
override func setUp() async throws {
continueAfterFailure = false
// The xctrunner sandbox only permits writes inside its own
// container the work dir lives there (the app can read/write
// it). Executable fixtures, however, must live outside the
// container or the app-under-test cannot posix_spawn them, so
// `bin` points at the committed Fixtures/bin scripts in the
// repo checkout (resolved via #filePath).
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Tests/ICCeryUITests
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
/// Force the fixture printtarg to exit with `code`.
private func failPrinttarg(exitCode: Int) {
app.launchEnvironment["ICCERY_MOCK_PRINTTARG_EXIT"] = "\(exitCode)"
}
/// Launch and bring the app to the front other app windows
/// (the IDE, notification banners) covering the test window count
/// as "interrupting elements" and stall synthesized clicks.
private func launchApp() {
app.launch()
app.activate()
}
/// Sheet content on macOS lives under `app.sheets`, outside the
/// main window's descendant tree probe both scopes.
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id]
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
// Poll both scopes so sheet-hosted elements resolve too.
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
private func staticText(_ exact: String) -> XCUIElement {
let inApp = app.staticTexts[exact]
if inApp.exists { return inApp }
return app.sheets.firstMatch.staticTexts[exact]
}
private func buttonsMatching(_ predicateFormat: String) -> XCUIElementQuery {
let pred = NSPredicate(format: predicateFormat)
let inApp = app.buttons.matching(pred)
if inApp.count > 0 { return inApp }
return app.sheets.firstMatch.buttons.matching(pred)
}
// MARK: - Tests
/// Stage 1 opens with the Standard 800-patch default; Generate stays
/// disabled until basename + cwd are valid (issue #7).
func testStage1DefaultsAndGenerateGate() throws {
launchApp()
XCTAssertTrue(waitFor("btnGenerate").exists)
XCTAssertTrue(element("patchCountPreset").exists)
XCTAssertTrue(element("targetBasename").exists)
XCTAssertTrue(element("btnOpenExisting").exists)
XCTAssertFalse(app.buttons["btnGenerate"].isEnabled)
// Browse fills basename + working dir via the test hook.
app.buttons["btnBrowse"].click()
XCTAssertTrue(app.buttons["btnGenerate"].isEnabled)
}
/// RGB/CMYK + advanced controls expose the documented identifiers
/// and the ink-limit group is hidden for RGB (issue #7).
func testStage1AdvancedVisibility() throws {
launchApp()
XCTAssertTrue(waitFor("targenAdvancedDetails").exists)
// RGB default: ink-limit group must not exist.
XCTAssertFalse(element("targenInkLimitGroup").exists)
// The ink-limit group lives inside the Advanced disclosure
// pre-expanded under UI testing (XCUI can't toggle a macOS
// DisclosureTriangle reliably). Switch the picker to CMYK.
XCTAssertTrue(element("targenAdvancedDetails").exists)
let cmyk = app.radioGroups["colourSpace"]
.radioButtons["CMYK (RIP output)"]
XCTAssertTrue(cmyk.waitForExistence(timeout: 5))
cmyk.click()
XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5))
}
/// Fixture-backed targen run creates .ti1 and unlocks Stage 2.
func testTargenFixtureUnlocksStage2() throws {
launchApp()
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti1").path))
}
/// Fixture printtarg .ti2, gallery page renders, print controls
/// stay disabled, Stage 3 advance becomes available (issues #9/#10).
func testPrinttargFixtureGalleryAndStubbedPrint() throws {
launchApp()
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
// Colour-management warning is always present on Stage 2.
XCTAssertTrue(element("cmWarningBanner").exists)
XCTAssertTrue(element("instrumentSelect").exists)
XCTAssertTrue(element("pageSizeSelect").exists)
XCTAssertTrue(element("tiffDpi").exists)
XCTAssertTrue(element("targetLabelPreview").exists)
app.buttons["btnCreateLayout"].click()
XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists)
XCTAssertTrue(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
// M3 stubs: visible but inert.
XCTAssertTrue(element("rawPrintPanel").exists)
XCTAssertFalse(app.buttons["btnPrintAll"].isEnabled)
XCTAssertFalse(app.buttons["btnPrintPage-0"].isEnabled)
XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled)
}
/// A failed printtarg run stays on Stage 2 (non-zero exit, #156).
func testPrinttargFailureStaysOnStage2() throws {
failPrinttarg(exitCode: 3)
launchApp()
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists)
app.buttons["btnCreateLayout"].click()
// The notice banner reports the failure and we never advance:
// btnCreateLayout is still the stage's action, and no gallery
// appears.
let failureText = element("noticeText")
XCTAssertTrue(failureText.waitForExistence(timeout: 20))
XCTAssertTrue((failureText.value as? String ?? "")
.contains("printtarg failed"))
XCTAssertTrue(element("btnCreateLayout").exists)
XCTAssertFalse(element("galleryPage-0").exists)
XCTAssertFalse(FileManager.default.fileExists(
atPath: workDir.appendingPathComponent("mytarget.ti2").path))
}
/// Resume: .ti1 jumps to Stage 2 (issue #8).
func testResumeTi1() throws {
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("old.ti1").path,
contents: Data("CGATS".utf8))
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
workDir.appendingPathComponent("old.ti1").path
launchApp()
app.buttons["btnOpenExisting"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists)
}
/// Resume: .ti2 with sibling .ti1 reaches the Stage 3 shell and
/// shows the persisted "Resumed from .ti2" state (issue #8).
func testResumeTi2ShowsStage3AndNotice() throws {
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("old.ti1").path,
contents: Data("CGATS".utf8))
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("old.ti2").path,
contents: Data("""
CTI2
TARGET_INSTRUMENT "i1"
NUMBER_OF_SETS 4
NUMBER_OF_PAGES 1
BEGIN_DATA_FORMAT
""".utf8))
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
workDir.appendingPathComponent("old.ti2").path
launchApp()
app.buttons["btnOpenExisting"].click()
XCTAssertTrue(waitFor("stage3TargetBasename", timeout: 10).exists)
XCTAssertTrue(element("stage3LoadedTargetBanner").exists)
let notice = element("noticeText")
XCTAssertTrue(notice.exists)
XCTAssertTrue((notice.value as? String ?? "")
.contains("Resumed from .ti2"))
}
/// A .ti2 without its sibling .ti1 must not advance (issue #8).
func testResumeTi2WithoutSiblingFails() throws {
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("orphan.ti2").path,
contents: Data("CTI2".utf8))
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
workDir.appendingPathComponent("orphan.ti2").path
launchApp()
app.buttons["btnOpenExisting"].click()
let err = element("noticeText")
XCTAssertTrue(err.waitForExistence(timeout: 10))
XCTAssertTrue((err.value as? String ?? "").contains("Cannot resume"))
XCTAssertTrue(element("btnGenerate").exists) // still Stage 1
}
/// Preset apply is bidirectional: the draft preset's 150 dpi must
/// be visible on Stage 2; built-ins cannot be deleted (issue #11).
func testPresetApplyAndBuiltInProtection() throws {
// Land on Stage 2 via a .ti1 resume so tiffDpi is visible.
FileManager.default.createFile(
atPath: workDir.appendingPathComponent("p.ti1").path,
contents: Data("CGATS".utf8))
app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] =
workDir.appendingPathComponent("p.ti1").path
launchApp()
// Sidebar preset picker is enabled; apply the draft preset.
let picker = app.popUpButtons["presetSelect"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
XCTAssertTrue(picker.isEnabled)
picker.click()
let draftItem = app.menuItems["Fast RGB Draft (400 patches)"]
XCTAssertTrue(draftItem.waitForExistence(timeout: 5))
draftItem.click()
app.buttons["btnOpenExisting"].click()
XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists)
// StaticText content is exposed via `value` on macOS, not `label`.
XCTAssertTrue(app.staticTexts
.matching(NSPredicate(format: "value CONTAINS 'DPI: 150'"))
.firstMatch.waitForExistence(timeout: 5))
// Manage dialog: built-ins show "Built-in" and have no delete.
app.buttons["btnOpenPresetsDialog"].click()
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
XCTAssertFalse(element("btnDeletePreset-preset-std-rgb").exists)
XCTAssertTrue(element("presetRow-preset-std-rgb").exists)
element("btnCloseManagePresetsDialog").click()
}
/// Save a custom preset through the dialog; it appears in the list
/// and can be deleted (issue #11).
func testSaveAndDeleteCustomPreset() throws {
launchApp()
app.buttons["btnSavePresetModal"].click()
XCTAssertTrue(waitFor("savePresetDialog", timeout: 10).exists)
let nameField = element("savePresetName")
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
nameField.click()
nameField.typeText("UI Test Preset")
element("btnConfirmSavePreset").click()
app.buttons["btnOpenPresetsDialog"].click()
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
XCTAssertTrue(staticText("UI Test Preset")
.waitForExistence(timeout: 5))
// The custom row is deletable (id prefix custom-).
let deleteButtons = buttonsMatching(
"identifier BEGINSWITH 'btnDeletePreset-'")
XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5))
deleteButtons.firstMatch.click()
XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3))
}
/// Export a preset to JSON and re-import it (issue #11).
func testPresetExportImport() throws {
let exportURL = testRoot.appendingPathComponent("export.json")
let importURL = testRoot.appendingPathComponent("import.json")
app.launchEnvironment["ICCERY_TEST_PRESET_EXPORT"] = exportURL.path
app.launchEnvironment["ICCERY_TEST_PRESET_IMPORT"] = importURL.path
launchApp()
// Save a custom preset first, then export it.
app.buttons["btnSavePresetModal"].click()
let nameField = element("savePresetName")
XCTAssertTrue(nameField.waitForExistence(timeout: 10))
nameField.click()
nameField.typeText("RoundTrip")
element("btnConfirmSavePreset").click()
// Export via the manage dialog.
app.buttons["btnOpenPresetsDialog"].click()
XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists)
let exportButtons = buttonsMatching(
"identifier BEGINSWITH 'btnExportPreset-'")
XCTAssertTrue(exportButtons.firstMatch.waitForExistence(timeout: 5))
exportButtons.firstMatch.click()
XCTAssertTrue(waitForFile(exportURL), "preset export file missing")
// Import must land back in the store (delete re-import).
let deleteButtons = buttonsMatching(
"identifier BEGINSWITH 'btnDeletePreset-'")
deleteButtons.firstMatch.click()
XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3))
// Copy the export to the import path so the hook picks it up.
try FileManager.default.copyItem(at: exportURL, to: importURL)
element("btnImportPreset").click()
XCTAssertTrue(staticText("RoundTrip")
.waitForExistence(timeout: 5))
}
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return false
}
}
+47
View File
@@ -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

+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="100%" height="100%">
<defs>
<clipPath id="coneClip">
<path d="M 50 82 L 110 82 L 80 142 Z" />
</clipPath>
</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"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+71
View File
@@ -0,0 +1,71 @@
# 01 — Product overview
ICCery is a native desktop GUI that walks a user through creating a printer ICC/ICM profile with ArgyllCMS. It is **not** a colour engine. All measurement, chart generation, and profile mathematics live in AGPLv3 Argyll binaries spawned as children. ICCery owns UI, artefact gating, unmanaged printing, and visualisation.
Version analysed: **0.8.5** (`com.gronod.iccery`).
## Platforms
| OS | Packaged as | Floor | Notes |
|----|-------------|-------|-------|
| Windows x86_64 | NSIS `.exe` + WiX `.msi` | WebView2 | `.icm` profiles; GDI ICM-off printing; optional Argyll USB driver install |
| macOS Intel + Apple Silicon | `.dmg` / `.app` | **12.0 Monterey** (`LSMinimumSystemVersion`) | Universal binary preferred; WKWebView; NSPrintPanel ColorSync suppression |
| Linux x86_64 | `.AppImage` / `.deb` | Ubuntu 22.04 glibc | CUPS `lp -o raw`; `libcups` |
macOS 11 and 10.15 are **not** supported after #225. Monterey Intel is best-effort: Stages 14 work if WebGL dies.
## User-facing workflow
```
[optional Stage 0] printcal / applycal linearization
Stage 1 targen → basename.ti1
Stage 2 printtarg + OS print → basename.ti2 + page TIFFs
Stage 3 chartread (+ average) → basename.ti3
Stage 4 colprof (+ applycal) → basename.icc|.icm
Stage 5 profcheck + iccgamut + install → verification, 3D gamut, OS profile store
```
Navigation is a left stepper. Forward motion is **artefact-gated on disk**, not on in-memory flags (#60, #151). Backward motion is always allowed.
## Colour spaces
- **RGB (Printer Driver)**`targen -d 2`. Host/driver colour management is expected to be **turned off** at print time; ICCery prints unmanaged.
- **CMYK (RIP)**`targen -d 4`. Typical for RIP-driven presses. Calibration (Stage 0) is recommended; Stage 1 shows a reminder when no `.cal` is applied.
## Instruments (Stage 2 layout + Stage 3 read)
`printtarg -i` codes used in the UI:
| UI | `-i` | Hardware |
|----|------|----------|
| i1 Pro / i1 Pro 2 | `i1` | Handheld strip (optional `-Y l` LEDs on i1Pro 2 Rev E) |
| ColorMunki | `CM` | Handheld |
| SpyderPrint | `p3` | Handheld (PrintFix Pro) |
| SpectroScan | `SS` | XY table |
| DTP20 / 22 / 41 / 51 | `20` `22` `41` `51` | Legacy X-Rite |
XY tables (SpectroScan, i1iO) are auto-detected from `instlist` names matching `/spectro\s?scan|i1io/i` and from `chartread` prompts (#93).
## AGPL boundary (non-negotiable)
ArgyllCMS is AGPLv3. ICCery is proprietary. The original design **never** `dlopen`s or statically links Argyll. Communication is:
- spawn with piped stdin/stdout/stderr
- `ARGYLL_NOT_INTERACTIVE=1` in the child environment
- structured JSON on stdout for tools compiled with the Gronod fork `-u` switch
- keystrokes on stdin for interactive `chartread`
A rewrite that in-process-links Argyll **contaminates the GUI with AGPL**. Keep the process boundary.
## Identity & assets
- Wordmark: waffle cone + CMY scoops (C `#00BCEB`, M `#EC008C`, Y `#FFED00`) + K cherry, text `ICC` white + `ery` cyan→blue. File: `src/assets/ICCery-logo.svg`.
- App icon: `src/assets/app-icon.svg` and raster set under `src-tauri/icons/`.
- Accent in UI CSS is VS Code blue `#007acc` on dark `#1e1e1e` / `#252526`.
- Window: 1280×800, min 1100×700, starts **hidden** until first paint (#225).
## What the rewrite must preserve
Everything in this spec is behavioural, not Tauri-specific: CLI flags, JSON prefixes, stdin bytes, PPD keys, ColorSync SPI, artefact names, ΔE bands, collision dialogs, and the bugs listed in [24-issues-invariants.md](24-issues-invariants.md).
+149
View File
@@ -0,0 +1,149 @@
# 02 — Architecture
## Host / child split
```
┌─ ICCery host (any stack) ─────────────────────────────────────┐
│ Wizard UI ─► WizardState (basename, cwd, printerName) │
│ │ │
│ ├─ ProcessManager (spawn / stdin / kill / kill_all) │
│ ├─ Print subsystem (Win GDI / macOS NSPrint+lp / Linux) │
│ ├─ Settings + presets (settings.json) │
│ ├─ Quality store (verification_history.json) │
│ ├─ Calibration library (.cal files) │
│ └─ Profile installer (OS colour stores) │
└──────────────┬────────────────────────────────────────────────┘
│ stdin / stdout / stderr pipes
│ env ARGYLL_NOT_INTERACTIVE=1
┌─ ArgyllCMS sidecars (AGPLv3) ─────────────────────────────────┐
│ instlist targen printtarg chartread average │
│ printcal applycal colprof profcheck iccgamut │
└───────────────────────────────────────────────────────────────┘
```
Original implementation: Tauri v2 webview (`withGlobalTauri: true`) so the vanilla JS frontend calls `window.__TAURI__.core.invoke` and `window.__TAURI__.event.listen`. A rewrite may replace this with any IPC (HTTP local server, native bindings, gRPC, etc.) but **must keep the same command semantics**.
## Frontend modules (legacy)
| Module | Role |
|--------|------|
| `app.js` | Boot, safeInit per stage, `show_main_window` double-rAF + 1500 ms fallback |
| `state.js` | `wizardState`, artefact gating, stage DOM, gamut pause/ensure |
| `targen.js` | Stage 1 |
| `printtarg.js` | Stage 2 + native print UI |
| `chartread.js` | Stage 3 state machine |
| `swatch_grid.js` | Live ΔE₀₀ patches from `process:json_row` |
| `colprof.js` | Stage 4 |
| `profcheck.js` | Stage 5 metrics, drift SVG, CSV |
| `gamut_viewer.js` | Three.js CIELAB viewer (lazy) |
| `calibration.js` | Stage 0 |
| `profile_install.js` | Stage 5 install |
| `settings.js` / `presets.js` | Persistence |
| `cgats_interop.js` | Import/export datasets |
| `delta_e.js` | CIEDE2000 |
| `color_convert.js` | Lab/device → CSS |
| `logger.js` | Forwards console to `log_frontend_message` |
**Do not** create WebGL during boot. `ensureGamutViewer()` runs only when Stage 5 becomes visible (#225).
## Backend modules (legacy Rust)
| Module | Role |
|--------|------|
| `lib.rs` | Tauri builder, plugins, `generate_handler!`, RunEvent kill-all |
| `commands.rs` | Binary resolve, arg builders, dialogs, stage artefact verify |
| `process_manager.rs` | tokio spawn, JSON-row split, CREATE_NO_WINDOW |
| `events.rs` | `process:stdout|stderr|exit|error|json_row` |
| `print/*` | OS printing |
| `calibration.rs` | printcal/applycal + `.cal` parser |
| `profile_install.rs` | OS colour-store copy |
| `quality_store.rs` | verification history, atomic write |
| `settings.rs` | settings + presets |
| `cgats.rs` | CGATS/ti3 parser + canonical serializer |
| `macos_webview.rs` | Dark WKWebView backing |
| `window_lifecycle.rs` | Close vs Web Content death |
## Sidecar layout
`scripts/fetch-argyll.mjs` downloads Gronod/argyllcms GitHub (or Gitea) releases into:
```
src-tauri/argyll/
linux-x86_64/instlist
windows-x86_64/instlist.exe
macos-x86_64/instlist
macos-aarch64/instlist
macos-universal/instlist
mocks/ # chartread.mock, colprof.mock, profcheck.mock
reference_gamuts/sRGB.gam
```
`resolve_binary(name)`:
1. If `settings.argyll_binary_dir` is set and the file exists, use it.
2. Else resource `argyll/<platform>/<name>[.exe]`.
3. On macOS, prefer `macos-universal` if that folder contains `instlist`.
4. Windows always tries `name.exe` first (#85).
Env override: `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`.
## Working directory
Every Argyll run is given an explicit cwd. Empty cwd falls back to Documents → Home → app data (`resolve_safe_cwd`, #59). Basename must not contain `/`, `\`, or `..`.
Default artefacts live next to each other:
```
<cwd>/<basename>.ti1
<cwd>/<basename>.ti2
<cwd>/<basename>.tif (and .1.tif, .2.tif … for multi-page)
<cwd>/<basename>.ti3
<cwd>/<basename>_passN.ti3 # averaging snapshots (#109)
<cwd>/<basename>.icc | .icm
<cwd>/<basename>.gam
<cwd>/CAL_<basename>.ti1|.ti2|.ti3|.cal # calibration, never collides
```
## Persistence locations
| File | Where | Notes |
|------|-------|-------|
| `settings.json` | app data dir | thresholds, argyll dir, presets, LED flag |
| `verification_history.json` | app data dir | max 1000 records, atomic `.tmp` + rename (#213) |
| `iccery.log` | app log dir | 5 MiB rotate, keep 5 historical segments |
| Calibration library | app data / user-chosen | `.cal` files |
macOS log path: `~/Library/Logs/com.gronod.iccery/iccery.log`.
## Event bus (must be replicated)
| Event | Payload | When |
|-------|---------|------|
| `process:stdout` | `{ id, line }` | Non-JSON stdout line |
| `process:stderr` | `{ id, line }` | stderr line |
| `process:exit` | `{ id, code }` | child exited (0 = success; killed may be 1) |
| `process:error` | `{ id, error }` | spawn failure |
| `process:json_row` | `{ id, json }` | stdout line starting `ROW_COLORS_JSON: ` — prefix **stripped** |
Process ids are deterministic strings, e.g. `targen_${basename}`, `chartread_${basename}`, `instlist`, `iccgamut_${stem}`. Duplicate spawn of a still-running id is rejected (#116).
Frontend listeners **must** filter on `payload.id`. A historical bug (#56) was process-id mismatch so UI never saw exit.
## Logging
- Host: `tauri-plugin-log` to log dir + stdout + webview. wry / tauri_runtime_wry at Info so Monterey "web content process terminated" is captured (#225).
- Subprocess stdout → `log::info!(target: "subprocess")`; stderr → warn.
- Paths in spawn logs are home-sanitized to `~` (`sanitize_arg_for_logging`).
- JS `logger.js` invokes `log_frontend_message`.
- Settings `log_level` is applied at startup **and** when saved (#158).
## Window / WebView contract (macOS especially)
See #225 and `macos_webview.rs`:
- Window `visible: false`, `backgroundColor: #1A1A22`.
- After CSS first paint: invoke `show_main_window` (double `requestAnimationFrame` + 1500 ms fallback).
- `paint_dark_webview`: `setBackgroundColor`, KVC `drawsBackground = NO`, `setUnderPageBackgroundColor:` on macOS 12+.
- Do **not** set `transparent: true` (hit-testing / titlebar).
- On `Exit` / `CloseRequested`: `kill_all` Argyll children (#147, #149) **before** teardown so `chartread` can park an XY head if the UI already sent `q\n`.
+64
View File
@@ -0,0 +1,64 @@
# 03 — IPC and process manager
The host never waits on a child from the request that spawned it (except `printcal`/`applycal`, which use captured `.output()`). Streaming tools go through a process manager that:
1. Rejects a duplicate `id` while that child is still mapped (#116).
2. Pipes stdin/stdout/stderr.
3. Sets `ARGYLL_NOT_INTERACTIVE=1`.
4. On Windows sets `CREATE_NO_WINDOW` (`0x08000000`) (#46).
5. Splits stdout: lines beginning `ROW_COLORS_JSON: ` become `process:json_row` (prefix stripped); everything else is `process:stdout`.
6. Reaps the child on natural exit **or** kill signal; then emits `process:exit`.
7. Drops stdin from the map on kill so writers fail fast.
## Commands the rewrite must expose
| Command | Args | Returns |
|---------|------|---------|
| `spawn_process` | `{ id, binary, args }` | `()` — unused by current JS (registered only) |
| `send_stdin` | `{ id, input }` | `()``input` is the **exact bytes**, already including `\n` |
| `kill_process` | `{ id }` | `()` |
| `kill_all_processes` | — | `usize` count signaled |
| `resolve_binary` | `{ binaryName }` | absolute path string |
| `run_targen` / `run_printtarg` / `run_chartread` / `run_average` / `run_colprof` / `run_profcheck` / `extract_gamut` / `detect_instruments` | typed configs | `()` after spawn (not after exit) |
| `generate_calibration_target` / `compute_calibration_curves` / `apply_calibration` | typed | captured result |
Frontend waits for `process:exit` with matching `id`. **Never** assume invoke() resolves when the tool finishes.
## Deadlock history (must not regress)
### ICCery #84 (P0)
Early ProcessManager held a `Mutex<Child>` across `Child::wait()`. `send_stdin` needed the same mutex → Calibrate/Retry hung. Dropping the mutex also dropped `ChildStdin` at spawn, closing the pipe immediately.
**Invariant:** stdin handle lives in its own map, independent of wait. Wait runs in a background task with a oneshot kill channel.
### ArgyllCMS fork #24 + ICCery #134
On Windows, `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes** created by `Stdio::piped()`. Argyll's `con_char(wait=0)` then `ReadFile`s a blocking pipe during `uicallback`, so the instrument trigger thread never sees the button and the lamp never lights.
Fork fix: `PeekNamedPipe` before `ReadFile` (`spectro/conv.c`). ICCery always sets `ARGYLL_NOT_INTERACTIVE=1` so Argyll uses the pipe path, not a console.
**Invariant:** ship the Gronod fork (or equivalent PeekNamedPipe patch). Stock Argyll 3.5.0 will hang interactive chartread on Windows.
### Kill on window close (#147, #149)
`chartread` outlives the UI if not killed. XY tables need `q\n` first to park the head, then kill. On `CloseRequested` / `Exit`, `kill_all` is mandatory. Frontend Cancel in `TABLE_*` states sends `q\n` then kills.
## stdin protocol
`send_stdin` writes UTF-8 bytes and flushes. ICCery strings (see [05](05-argyll-fork.md) §12.5):
| UI | Bytes | Meaning in real chartread |
|----|-------|---------------------------|
| Calibrate / Retry strip / Accept (many states) | `" \n"` or `"\n"` | Space or Return = trigger (`DUIH_TRIG`) |
| Done & Save | `"d\n"` | finish and write `.ti3` |
| Skip | `"s\n"` | **not a skip in real strip mode** — treated as trigger. Mock/UI invention. Rewrite should verify against fork `chartread.c` before advertising Skip. |
| Undo | `"u\n"` | same caveat |
| XY cancel | `"q\n"` | abort / park |
| Warning accept | `"\n"` | "use it anyway" |
Always include the newline. Argyll line-buffers prompts.
## Logging hygiene
Spawn argv is logged with home directories rewritten to `~` (case-insensitive on Windows). Raw argv only at debug.
+912
View File
@@ -0,0 +1,912 @@
# 04 — Argyll binary invocations
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Source tree: `/tmp/ICCery` (v0.8.5). ICCery never links Argyll; every binary is spawned as an isolated subprocess (`calibration.rs:14`). Sidecars come from the ICCery-patched fork `gronod/argyllcms` (GitHub `Gronod/argyllcms`), not stock Graeme Gill builds.
Two spawn paths exist:
| Path | Used by | Streams | Events |
|---|---|---|---|
| `ProcessManager::spawn` | targen, printtarg, chartread, average, colprof, profcheck, iccgamut, instlist | piped stdin/stdout/stderr, line-by-line | `process:stdout`, `process:stderr`, `process:exit`, `process:error`, `process:json_row` |
| `calibration::run_captured` | printcal, applycal | `.output().await` (full capture) | none — returns `(exit_code, stdout, stderr)` to the Tauri command |
---
## 0. Shared infrastructure
### 0.1 Binary resolution — `resolve_binary`
`commands.rs:40106`. Public Tauri command **and** internal helper.
Order of search:
1. Settings `argyll_binary_dir` (Settings dialog, `AppSettings.argyll_binary_dir`). If non-empty, join each candidate name; first existing path wins.
2. Bundled sidecar at `argyll/{platform}/{name}` under Tauri `BaseDirectory::Resource`.
3. If the resource path does not exist, **still return the constructed resource path** (does not search `$PATH`). Missing binaries surface later as spawn `process:error`.
`get_binary_candidates` (`commands.rs:4046`):
- Windows: `["{name}.exe", "{name}"]` unless `name` already ends with `.exe`.
- Unix: `["{name}"]`.
Platform directory selection (`commands.rs:6583`, mirrored in `build.rs:2644`):
| OS / arch | Resource dir |
|---|---|
| linux x86_64 | `linux-x86_64` |
| windows x86_64 | `windows-x86_64` |
| macos aarch64 | `macos-universal` if `argyll/macos-universal/instlist` exists, else `macos-aarch64` |
| macos x86_64 | `macos-universal` if that marker exists, else `macos-x86_64` |
| anything else | `linux-x86_64` (fallback) |
`default_instrument` is stored in settings (`settings.rs:76`) and shown in the Settings dialog, but **is never read when building any Argyll argv**. Instrument comes from Stage 2 `#instrumentSelect`.
### 0.2 Working directory — `resolve_safe_cwd`
`commands.rs:203217`. If `cwd_input` is a real directory, use it. Else: `document_dir``home_dir``app_data_dir`.
All specialised `run_*` commands pass `Some(resolve_safe_cwd(&app, &config.cwd))`. Exceptions:
- `detect_instruments` / `spawn_process`: `cwd = None` (inherit parent cwd).
- `extract_gamut`: parent of the ICC file, or default cwd if empty.
- `apply_calibration`: parent of the input profile.
### 0.3 Environment variables
Set on **every** spawn (ProcessManager **and** `run_captured`):
```
ARGYLL_NOT_INTERACTIVE=1
```
- ProcessManager: `process_manager.rs:97`
- `run_captured`: `calibration.rs:848`
This is the ICCery-side half of `gronod/argyllcms#24` (Windows anonymous-pipe deadlock). It forces Argyll's `check_if_not_interactive()` path so `con_char()` uses pipe I/O instead of a console. Added in ICCery#134 (`3d514b6`).
**Never set:**
- `ARGYLL_3D_DISP` — grep of the tree is empty. `iccgamut` is invoked without any 3D-display env; the `.gam` file is parsed in JS (`gamut_viewer.js`).
- `PATH` — not mutated. Binaries are always absolute paths from `resolve_binary`. Inherited PATH is whatever the OS/session provides (needed only if a user-supplied `argyll_binary_dir` binary dlopens something).
### 0.4 Windows `CREATE_NO_WINDOW`
Both spawn paths:
```rust
const CREATE_NO_WINDOW: u32 = 0x08000000; // 0x08000000
command.creation_flags(CREATE_NO_WINDOW);
```
- ProcessManager: `process_manager.rs:99103`
- `run_captured`: `calibration.rs:849853`
Fixes ICCery#46 (v0.1.6, `ec6205b`): Argyll tools are `IMAGE_SUBSYSTEM_WINDOWS_CUI`; without this flag a black console covers the UI.
`CommandExt` is pulled in via `tokio::process::Command::creation_flags` (tokio re-exports the Windows ext).
### 0.5 Event schema (ProcessManager only)
`events.rs`:
```rust
ProcessEventPayload { id: String, line: Option<String>, code: Option<i32>, error: Option<String> }
JsonRowPayload { id: String, json: String }
```
| Event | When | Fields |
|---|---|---|
| `process:stdout` | every stdout line **except** those starting `ROW_COLORS_JSON: ` | `id`, `line` |
| `process:stderr` | every stderr line | `id`, `line` |
| `process:json_row` | stdout line prefixed `ROW_COLORS_JSON: ` (prefix stripped) | `id`, `json` (raw JSON string) |
| `process:exit` | child reaped | `id`, `code` (`status.code().unwrap_or(0)` on natural exit; `unwrap_or(1)` on kill) |
| `process:error` | `Command::spawn` failed | `id`, `error` |
Stdout reader (`process_manager.rs:128139`):
```rust
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
if line.starts_with(JSON_ROW_PREFIX) {
emit_json_row(..., line[JSON_ROW_PREFIX.len()..]);
} else {
emit_stdout(...);
}
```
JSON-row lines are **not** forwarded as `process:stdout` and are **not** written to the subprocess log as info lines.
Logging: spawn logs sanitised argv (`~` for `$HOME`/`%USERPROFILE%`) at info, raw argv at debug (`process_manager.rs:2860, 105119`). Each stdout line → `log::info!`, stderr → `log::warn!`, exit → `log::info!`.
### 0.6 Sidecar fetch / bundle
`scripts/fetch-argyll.mjs` downloads from `https://github.com/Gronod/argyllcms/releases` (override: `ARGYLL_SERVER_URL`, `ARGYLL_REPO`, `ARGYLL_RELEASE_TAG`). Marker binary is `instlist` / `instlist.exe`. Windows also copies `usb/` (`ArgyllCMS_install_USB.exe`, `ArgyllCMS.inf`) to `src-tauri/argyll/usb/`.
`tauri.conf.json:3840`: `"resources": ["argyll/**/*"]`.
`build.rs:1964` panics the compile if the platform marker is missing (`npm run fetch-argyll` required).
NSIS (`windows/hooks.nsh:133158`): admin install prompts “Install ArgyllCMS USB instrument drivers?” and `ExecWait`s `ArgyllCMS_install_USB.exe`. Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe`.
---
## 1. `targen` — Stage 1 patch generation **and** Stage 0 calibration chart
### 1.1 When
| UI | Command | Process id |
|---|---|---|
| Stage 1 **Generate** (`#btnGenerate`) | `run_targen` | `targen_{basename}` |
| Stage 0 **Generate Calibration Target** (`#btnCalGenerate`) | `generate_calibration_target` | `targen_{CAL_basename}` |
`run_targen`: `commands.rs:952964`. `generate_calibration_target`: `calibration.rs:598613`. Both go through ProcessManager.
Stage 0 prefixes the basename with `CAL_` (`calibration_basename`, never double-prefix). Calibration charts must not collide with the profiling `.ti1`.
### 1.2 Profiling argv — `build_targen_args` (`commands.rs:788905`)
Always starts `-v -d {2|4}`. Colour space is the only discriminator for `-d`: RGB → `2`, CMYK → `4`. No other colourant counts.
| UI field (`#id` / config) | Flag | Condition |
|---|---|---|
| `#colourSpace` radio (`colour_space`) | `-d 2` or `-d 4` | always |
| `#patchCountPreset` / `#patchCountCustom` (`patch_count`, else `total_patches`) | `-f N` | N > 0; JS default 800 |
| `#whitePatches` (`white_patches`) | `-e N` | Some |
| `#blackPatches` (`black_patches`) | `-B N` | Some. JS: RGB default 4, CMYK default 0 on colour-space change |
| `#targenGreySteps` (`grey_steps`) | `-g N` | Some and N > 0 |
| `#targenSingleChannelSteps` (`single_channel_steps`) | `-s N` | Some and N > 0 |
| `#targenPrecondProfile` (`preconditioning_profile`) | `-c PATH` | non-empty trim |
| `#targenNeutralSteps` (`neutral_steps`) | `-n N` | Some and N > 0 |
| `#targenNeutralConcentration` (`neutral_concentration`) | `-N x.xx` | Some and `|x-0.50| > 0.001` (slider default 0.50 → omitted) |
| `#targenHighQuality` (`ofps_high_quality`) | `-G` | `Some(true)` |
| `#targenAdaptation` (`ofps_adaptation`) | `-A x.xx` | Some (even 0.10 — **no** default-skip) |
| `#targenAlgorithm` (`full_spread_algorithm`) | `-t` `-r` `-R` `-q` `-Q` `-i` `-I` | value in that set; `"ofps"` / default → no flag |
| `#targenInkLimit` (`total_ink_limit`) | `-l N` | **CMYK only**, 1..=400 |
| `#targenDarkEmphasis` (`dark_emphasis`) | `-V x.xx` | Some and `|x-1.0| > 0.001` |
| `#targenDevicePower` (`device_power`) | `-p x.xx` | Some and `|x-1.0| > 0.001` and `x > 0` |
| `#targetBasename` | positional | last arg, no extension |
**Not passed:** `-u` (Argyll fork has `targen -u` JSON progress — ICCery never enables it). `-v` always.
JS config construction: `targen.js:295314`. On success (`code === 0`) advances to Stage 2 via `setStage1Result` + `wizardState.navigateToStage(2)`.
### 1.3 Calibration argv — `build_calibration_targen_args` (`calibration.rs:146189`)
Hard-wired for a short per-channel wedge, **not** a full-spread profile chart:
```
-v -d {2|4} -s {steps} -g {steps} [-n {steps}] -e {white|4} [-l TAC] -f 0 {CAL_basename}
```
| UI / config | Flag | Condition |
|---|---|---|
| `#calColourSpace` | `-d 2` / `-d 4` | rgb / cmyk |
| `#calSteps` (`steps_per_channel`) | `-s N` and `-g N` | clamped 11..=51 (`DEFAULT_STEPS=21`) |
| `#calNeutralEmphasis` | `-n N` (same N) | checked |
| `#cal` white_patches (JS always sends `4`) | `-e 4` | if `None`, code also defaults to `-e 4` |
| `#calInkExplore` | `-l N` | CMYK only, 200..=400 |
| (hardcoded) | `-f 0` | always — “Full-spread patches are not useful on a calibration wedge” |
| `channels` field | — | **unused** in the builder |
Basename is sanitised (no `/` `\\` `..`). JS: `calibration.js:399410`.
### 1.4 Env / cwd / stdin
- cwd = `resolve_safe_cwd(config.cwd)` (Stage 1 browse dir / wizard cwd).
- `ARGYLL_NOT_INTERACTIVE=1`, Windows `CREATE_NO_WINDOW`.
- **No stdin protocol.** targen is batch.
### 1.5 stdout / exit
Frontend appends every `process:stdout`/`stderr` line into `#targenLog` / `#calLog`. Success = `code === 0`. No regex parsing.
### 1.6 Artefacts
Consumes: nothing required (optional `-c` ICC/ICM/MPP).
Produces in cwd:
- `{basename}.ti1` (always) — Stage 1 complete gate (`verify_stage_artefacts`).
- Calibration: `CAL_{name}.ti1`.
### 1.7 Tests
Rust: `commands.rs:14811628` (RGB, CMYK, total_patches fallback, all-advanced, RGB ignores `-l`). Calibration: `calibration.rs:903936` (RGB no `-l`, CMYK `-l 320` + `-n`, path-separator reject). JS: `calibration.test.js` basename prefix only (no argv).
---
## 2. `printtarg` — Stage 2 layout (also used by Stage 0 “Create Layout”)
### 2.1 When
Stage 2 **Create Layout** (`#btnCreateLayout`) → `run_printtarg` (`commands.rs:966978`). Process id `printtarg_{basename}`.
Stage 0 **Create Layout & Print** (`#btnCalLayout`) does **not** spawn printtarg itself: it sets `wizardState.sessionMode = 'calibration'`, copies the `CAL_` basename into Stage 2 via `setStage1Result`, and navigates to Stage 2. The user then hits Create Layout. `getPrinttargCalibrationFields('CAL_…')` returns `{calibration_file: null}` so `-K` is **never** applied to the calibration chart itself (`calibration.js:108116`, `AGENTS.md:7`).
### 2.2 argv — `build_printtarg_args` (`commands.rs:907950`)
Always:
```
-v -u -i {instrument} -p {page_size} [ -r | -R {seed} ] [-d {label}] {-t|-T} {dpi} [-K|-I {cal}] {basename}
```
| UI field | Flag | Notes |
|---|---|---|
| `#instrumentSelect` | `-i {code}` | `i1` (default), `p3`, `CM`, `SS`, `20`, `22`, `41`, `51` (`PrinttargConfig` comment `commands.rs:768`) |
| `#pageSizeSelect` / custom W×H | `-p {size}` | A4, A4R, A3, A2, Letter, LetterR, Legal, 4x6, 11x17, or `{W}x{H}` mm (JS requires ≥50) |
| `#printtargLayoutOrder` | `-R 1` (default), `-R {seed}`, or `-r` | `no_randomize` **supersedes** seed (`commands.rs:917922`). Default `random_seed: Some(1)` (`commands.rs:762764`) for ICCery#163 determinism |
| `#targetLabelPreview` (`custom_label`) | `-d {string}` | assembled `ICCery - {run} - {printer} - {ink} - {driver paper} - {actual paper} - DD/MM/YYYY HH:MM`. Argyll fork `argyllcms#19` |
| bit-depth radios | `-t {dpi}` (8-bit) or `-T {dpi}` (16-bit) | dpi from `#tiffDpi`, default 300 |
| calibration (`getPrinttargCalibrationFields`) | `-K {cal}` apply, or `-I {cal}` embed-only | only if Apply Calibration on **and** basename is **not** `CAL_*` |
| Stage 1 basename | positional | last |
**`-u` is always on.** That is the ICCery-patched JSON manifest (`argyllcms#3`).
### 2.3 JSON event schema (`-u` manifest)
Parsed in JS from the **accumulated stdout** after exit, not via `process:json_row` (`printtarg.js:680691`):
```js
stdout.match(/\{[\s\S]*?"event"\s*:\s*"manifest"[\s\S]*?\n\}/)
```
Expected object (from Argyll issue #3 and gallery use):
```json
{
"event": "manifest",
"pages": [
{ "filename": "target_01.tif", "patches": 800, "width_mm": 210, "height_mm": 297 }
]
}
```
`renderTiffGallery` uses `page.filename`, `page.patches`, `pages[0].width_mm/height_mm`. TIFF is previewed via `read_tiff_preview_png` (decode TIFF → PNG ≤1200px, base64).
### 2.4 stdin / env / cwd
No stdin. cwd = Stage 1 working dir. `ARGYLL_NOT_INTERACTIVE=1`, `CREATE_NO_WINDOW`.
### 2.5 Exit
`code === 0` → parse manifest, show gallery + raw-print panel, `setStage2Result`. Non-zero → log error, stay on Stage 2. Native print (`print_target_native`) is **not** an Argyll call (GDI / CUPS / NSPrintPanel).
### 2.6 Artefacts
Consumes: `{basename}.ti1` in cwd.
Produces:
- `{basename}.ti2` — Stage 2 gate.
- One or more TIFF pages named in the manifest (typically `{basename}.tif` or `{basename}_NN.tif`). 8-bit (`-t`) vs 16-bit (`-T`).
- If `-K`/`-I`: calibration is applied to / embedded in the printed patches; `.cal` is not copied.
### 2.7 Tests
`commands.rs:16311802`: i1/A4/8-bit, CM/Letter/16-bit, custom `200x400`, custom label, custom seed 42, raster `-r` (seed ignored), `-K`, `-I` embed-only. JS: `calibration.test.js` asserts CAL_ charts skip `-K`.
---
## 3. `chartread` — Stage 3 measurement (and Stage 0 “Measure Chart”)
### 3.1 When
Stage 3 **Start Measurement** (`#btnStartRead`) → `run_chartread` (`commands.rs:10451061`). Process id `chartread_{basename}`.
Stage 0 **Measure Chart** navigates to Stage 3 with the `CAL_` basename; the same `run_chartread` path is used.
`enable_i1pro2_leds` is **not** sent by JS. If the config field is `None`, the command loads `AppSettings.enable_i1pro2_leds` (`commands.rs:10511054`). Default `false` (stock Argyll compatibility; ICCery#204 / Argyll#37). The fork flag is `-Y l` (not the earlier proposed `-L`).
### 3.2 argv — `build_chartread_args` (`commands.rs:10231043`)
```
-v -u [-c {port}] [-Y l] {basename}
```
| UI / state | Flag | Condition |
|---|---|---|
| (always) | `-v -u` | `-u` = `ROW_COLORS_JSON` stream (`argyllcms#1`) |
| `#chartreadInstrumentSelect` (`port`) | `-c {port}` | non-empty. Port `"1"` is stored as `""` by the detector so default port is used (`chartread.js:488489`). ICCery#111: do **not** pass instlist device index as `-c`. |
| Settings `enable_i1pro2_leds` | `-Y l` | true. LEDs: white=cal, blue=ready, red=error, green=capture. Unpatched binaries reject `-Y l`; frontend captures `lastStderrLine` and opens the log. |
No `-p` (spot), `-t` (transmissive), `-N` (skip cal), `-H`, `-F`, `-r` resume, `-n`.
### 3.3 JSON event schema (`-u` / `ROW_COLORS_JSON`)
Intercepted in ProcessManager, emitted as `process:json_row`. Frontend: `swatch_grid.js:95116`. Ignored unless `data.event === "row_complete"`.
```json
{
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 12,
"patch_count": 21,
"patches": [
{
"id": "1",
"loc": "A1",
"is_pad": false,
"device": [0.0, 50.0, 100.0],
"expected": { "XYZ": [18.42, 20.12, 15.68], "Lab": [51.98, -8.45, 12.32] },
"measured": { "XYZ": [...], "Lab": [...], "spectral": { "bands": 36, "start_nm": 380, "end_nm": 730, "norm": 100, "values": [...] } }
}
]
}
```
`is_pad` patches are skipped only when they have no `measured` **and** all-zero `device` (`swatch_grid.js:137141`) so white-reference pads from targen `-e` still render.
On `row_index + 1 >= total_rows` the swatch listener forces `STATE.ALL_STRIPS_READ`.
Mock: `src-tauri/argyll/mocks/chartread.mock` (handheld + `--xy` / `MOCK_XY_TABLE=1`).
### 3.4 stdin protocol
All via `send_stdin` (`commands.rs:1623``ProcessManager::send_stdin`). Bytes are written **as-is** and flushed. No extra newline is added by Rust — JS includes `\n`.
| Button | State(s) | Bytes | Why |
|---|---|---|---|
| `#btnCalibrate` | `CALIBRATING` | `" \n"` (space + LF) | Argyll “hit any key / space to calibrate” |
| `#btnAccept` | `WARNING`, `PROMPT_CONTINUE`, `TABLE_PLACE_SHEET`, `TABLE_ALIGN` | `"\n"` | Continue / accept strip / sheet placed / fiducial aligned. TABLE_* does **not** force `READING` |
| `#btnRetry` | `AWAITING_STRIP`, `ALL_STRIPS_READ`, `WARNING`, `ERROR` | `" \n"` | Re-read strip |
| `#btnDoneRead` | `AWAITING_STRIP`, `ALL_STRIPS_READ` | `"d\n"` | Write `.ti3` and exit (ICCery#175) |
| `#btnUndo` | strip states | `"u\n"` | Undo last strip |
| `#btnSkip` | `AWAITING_STRIP`, `ERROR` | `"s\n"` | Skip current strip |
| `#btnCancel` | any; XY extra | `"q\n"` then 500 ms then `kill_process` | Park XY head (`AGENTS.md:170`) then SIGKILL-equivalent |
`send_stdin` errors with `"Process not found or stdin not available"` if the id is not in `stdins`.
### 3.5 stdout state machine — `classifyChartreadLine` (`chartread.js:70286`)
Pure function. Priority order:
1. “remove last sheet” → info, `isRemoveSheetNotice`, **state unchanged** (Argyll emits this just before writing `.ti3`).
2. `/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i``sheetOk` meta.
3. `/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i``TABLE_ALIGN`.
4. `/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i` or “place sheet” / “remove previous sheet” → `TABLE_PLACE_SHEET`.
5. “hit return to continue” (and not “use it anyway”) → sticky `TABLE_*` if already there, else `PROMPT_CONTINUE`.
6. `'d' if/when done`, “all strips/patches read”, “done reading” → `ALL_STRIPS_READ`.
7. “(warning)”, “use it anyway”, “seem to have read strip pass”, “unexpected response” → `WARNING`.
8. place + (reference|white|calibrat|standard) **or** “hit any key to continue” **or** “calibration”, excluding place-sheet/locate-patch → `CALIBRATING`.
9. “hit … read … strip”, “ready to read”, “read … strip … key” → `AWAITING_STRIP`.
10. “reading strip/sheet”, “processing”, “scanning” → `READING`.
11. “error”, “too fast/slow”, “misread”, “failed to read” → `ERROR`.
XY table is auto-detected from these prompts **or** from instlist `data-xy="1"` (`/spectro\s?scan|i1io/i`).
### 3.6 Exit / snapshot
`code === 0``snapshot_ti3` copies `{basename}.ti3``{basename}_pass{N}.ti3` and **deletes** the canonical `.ti3` so Stage 4 stays locked (ICCery#109/#110, `commands.rs:11121131`). `setStage3Result` is **not** called until Finish.
`code !== 0` → prompt shows last stderr line; log `<details>` opened.
Single pass Finish → `promote_ti3` restores `{basename}.ti3` from `*_pass1.ti3` (average is **not** invoked). Multi-pass → `run_average`.
Cancel: `kill_process` after optional `q\n`.
### 3.7 Artefacts
Consumes: `{basename}.ti2` (and the printed chart).
Produces: `{basename}.ti3` (ephemeral) then `{basename}_passN.ti3`. Canonical `.ti3` only after Finish/average.
### 3.8 Tests
Rust argv: `commands.rs:18051874` (auto, empty port, `-c 1`, `-Y l`, both, leds disabled). Snapshot roundtrip: `18981934`. JS classifier: `chartread.test.js` (39 cases, XY sticky continuation, strip mode, warnings). Mock script as above.
---
## 4. `instlist` — instrument detection
### 4.1 When
Stage 3 **Detect** (`#btnDetectInstruments`) → `detect_instruments` (`commands.rs:615619`).
```rust
let binary = resolve_binary(..., "instlist")?;
state.spawn(app, "instlist".to_string(), binary, vec![], None).await
```
**Empty argv. cwd = None.** Process id is the literal `"instlist"` (not namespaced). Duplicate Detect clicks while running → `"Process 'instlist' is still running"` (#116).
This is the Argyll fork USB enumeration API (`argyllcms#6`). ICCery does **not** pass `-u`; the forks `instlist` prints JSON on stdout by default (or ICCery treats the whole stdout as JSON).
### 4.2 stdout parsing (`chartread.js:438503`)
Accumulate stdout. On exit:
1. `JSON.parse(trimmed)` looking for `{ devices: [ { port, name, type } ] }`.
2. Fallback regex: `/^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?/i` plus `KNOWN_INST_TOKENS = /i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein/i`.
Each device → `<option value="{port if port!=='1' else ''}">`. SpectroScan / i1iO get `data-xy="1"` and ` · XY Table`.
Default option: `"Auto (First available port)"` with empty value → no `-c`.
### 4.3 stdin / artefacts
None. No files.
---
## 5. `average` — multi-pass ti3 merge
### 5.1 When
Stage 3 **Finish & Average** with ≥2 recorded passes (`chartread.js:864947`) → `run_average` (`commands.rs:10791091`). Process id `average_{output}` e.g. `average_job.ti3`.
### 5.2 argv — `build_average_args` (`commands.rs:10701077`)
```
-v {input1} {input2} ... {output}
```
JS:
```js
{ inputs: recordedPasses.map(p => p.filename), // "job_pass1.ti3", ...
output: `${basename}.ti3`,
cwd }
```
No other flags. Inputs are **relative names** (cwd is the project dir).
### 5.3 Exit
`code === 0``setStage3Result`, Stage 4. Else fallback `promote_ti3` of pass 1.
No stdin. `ARGYLL_NOT_INTERACTIVE=1`.
### 5.4 Artefacts
Consumes: `{basename}_passN.ti3`. Produces: `{basename}.ti3` (Stage 3 gate).
### 5.5 Tests
`commands.rs:18771896`.
---
## 6. `colprof` — Stage 4 profile calculation
### 6.1 When
Stage 4 **Calculate Profile** (`#btnCreateProfile`) → `run_colprof` (`commands.rs:12431255`). Process id `colprof_{basename}`.
**`-u` is NOT passed** even though the fork has `colprof -u` progress JSON (`argyllcms#4`). Progress is guessed from plaintext stdout.
### 6.2 argv — `build_colprof_args` (`commands.rs:11761241`)
Always `-v -a {algorithm} -q {quality}`.
| UI | Flag | Condition |
|---|---|---|
| `#colprofAlgorithm` | `-a l\|x\|X\|m` | `l` Lab cLUT (default), `x` XYZ cLUT, `X` Display XYZ+matrix, `m` matrix |
| `#colprofQuality` | `-q l\|m\|h\|u` | `m` Medium default. `u` = Ultra |
| `intent` (preset field only) | `-t {intent}` | **no Stage 4 control**. Presets leave it `None`. Tests use `"p"` / `"a"` |
| `#colprofFwa` / custom `.sp` | `-f {val}` | `"none"` → omit; `"D50"`/`"D65"`/`path.sp``-f VAL`; empty string → bare `-f` |
| `#colprofIlluminant` | `-i A\|C\|D50M2\|D65\|F5\|F8\|F10` | empty = default D50, omit flag |
| `#colprofObserver` | `-o 1964_10\|2015_2\|2015_10` | empty = 1931 2°, omit |
| `#colprofInputViewCond` | `-c pc\|pp\|pe\|pm` | `"none"` omit |
| `#colprofOutputViewCond` | `-d mt\|mb\|md\|jm\|jd\|tv` | `"none"` omit |
| `#colprofDescription` | `-D {text}` | non-empty; JS falls back to basename |
| `#colprofCopyright` | `-C {text}` | non-empty |
| basename | positional | last |
JS config: `colprof.js:95107`. Custom FWA: `fwa: colprofCustomSpPath.value \|\| "none"`.
### 6.3 stdout “progress”
`colprof.js:119126` (case-insensitive):
- contains `"gamut mapping"` → “Gamut mapping calculation in progress...”
- `"fitting"` or `"clut"` → “Fitting cLUT grid points...”
- `"writing"` or `"icc profile"` → “Writing ICC profile header & tags...”
Mock (`colprof.mock`) emits exactly those phrases.
### 6.4 Exit / follow-on
`code === 0``get_profile_path` (`.icm` if it exists and `.icc` does not; else `.icc`; Windows default extension `.icm` if neither exists, Unix `.icc``commands.rs:621639`). Then if Apply Calibration: `apply_calibration` (see §8) in-place. Then `extract_gamut`.
`intent` is in `ColprofConfig` and presets but the Stage 4 form never sets it.
### 6.5 Artefacts
Consumes: `{basename}.ti3`. Produces: `{basename}.icc` (Unix/macOS) or `{basename}.icm` (Windows). Stage 4 gate.
### 6.6 Tests
`commands.rs:19372099` (base, FWA D50/none/custom .sp, illuminant+observer, viewing conditions, combined).
---
## 7. `applycal` — embed/unembed calibration curves
### 7.1 When
Not ProcessManager. `apply_calibration` (`calibration.rs:703762`) uses `run_captured`.
Callers:
1. Automatically after successful colprof (`colprof.js:163171`) if `cal.applyEnabled && cal.calPath`. `output_path: null` → in-place replace via `{input}.applycal.tmp` then `rename`.
2. Any future caller of the Tauri command.
### 7.2 argv — `build_applycal_args` (`calibration.rs:237261`)
```
-v {-a|-u} {cal_path} {input_path} [{output_path}]
```
| Config | Flag |
|---|---|
| always | `-v` |
| `unapply: false` (JS always) | `-a` (apply) |
| `unapply: true` | `-u` (**unapply**, NOT JSON) |
| `cal_path`, `input_path` | required; empty → Err |
| `output_path` | optional positional |
Note: applycal `-u` conflicts in meaning with every other tools `-u` (JSON). ICCery never sends `unapply: true` from JS.
cwd = parent of input profile. `ARGYLL_NOT_INTERACTIVE=1`, `CREATE_NO_WINDOW`. Exit ≠ 0 → delete tmp, return last stderr line.
### 7.3 Artefacts
Consumes: `.cal` + `.icc`/`.icm`. Produces: same path (in-place) or `output_path`. Temp extension `.applycal.tmp`.
### 7.4 Tests
`calibration.rs:9871015`.
---
## 8. `printcal` — compute `.cal` from measured calibration chart
### 8.1 When
Stage 0 **Compute Curves** (`#btnCalCompute`) → `compute_calibration_curves` (`calibration.rs:615701`). **Captured**, not streamed. Requires `{ti3_basename}.ti3` already on disk.
Collision: if `{basename}.cal` exists and `force_overwrite` is false → error string containing `"already exists"`; JS dialog Overwrite / Rename / Cancel (`calibration.js:418437, 485492`). Rename uses `{basename}_{ISO-stamp}.cal`.
### 8.2 argv — `build_printcal_args` (`calibration.rs:191235`)
Always `-v -e`. Then:
| Config / UI | Flag | Condition |
|---|---|---|
| `no_ink_limit` (JS always false) | `-I` | true |
| `verify` (JS always false) | `-z` | true |
| `previous_cal` (current `calState.calPath`) | `-a {path}` | non-empty |
| `#calTacOverride` (`total_ink_limit`) | `-m x.x` | Some and > 0 |
| `#calInkLimitControls` (`channel_limits`) | `-x{C} {percent}` | first char of channel, e.g. `-xC 95.0` |
| `output_cal` | `-o {file.cal}` | default `{basename}.cal` |
| `ti3_basename` | positional | last |
`-e` here is printcals even/estimate switch (always on), **not** targen white patches.
### 8.3 stdout parsing — `parse_printcal_stdout` (`calibration.rs:274307`)
Line-oriented, not JSON:
- line matching `ideal power` / `device power` / `power value` → first number → `recommended_power` (feeds Stage 1 targen `-p` hint in the dashboard).
- line containing `total` and (`ink`|`tac`|`limit`) → TAC.
- line starting Cyan/Magenta/Yellow/Black/Red/Green/Blue or `C:`/`C ` etc. → per-channel limit.
Then `parse_cal_file` on the written `.cal` (CGATS: `COLOR_REP`, `CREATED`, `DESCRIPTOR`, `MAX_TAC`/`TOTAL_INK_LIMIT`/`INK_LIMIT`, `INK_LIMIT_*`, `BEGIN_DATA_FORMAT` / `BEGIN_DATA` curves). If stdout limits empty, infer from curve max×100.
### 8.4 Artefacts
Consumes: `CAL_*.ti3`. Produces: `{basename}.cal` (or override). Also `iccery-calibration.json` project state and optional library copy under `app_data_dir/calibrations/`.
### 8.5 Tests
`calibration.rs:939984, 10181072`.
---
## 9. `profcheck` — Stage 5 verification
### 9.1 When
Stage 5 **Verify** (`#btnVerify`) → `run_profcheck` (`commands.rs:12751302`). Process id `profcheck_{ti3_path}` (full path, not basename — `colprof.js` uses basename; these must not be confused; #56 was exactly that class of bug for colprof).
ICC path auto-flips `.icc``.icm` if the requested file is missing (`commands.rs:12831295`).
### 9.2 argv — `build_profcheck_args` (`commands.rs:12641273`)
Hard-coded:
```
-v -k -s -u {ti3_path} {icc_path}
```
| Flag | Meaning in this fork |
|---|---|
| `-v` | verbose |
| `-k` | CIEDE2000 |
| `-s` | (Argyll: “standard” / summary — kept for the text line parser) |
| `-u` | JSON report (`argyllcms#5`) |
No UI toggles. Paths may be absolute (JS builds `cwd + sep + basename.ti3` / `get_profile_path`).
### 9.3 JSON + text parsing — `parseProfcheckReport` (`profcheck.js:27139`)
Order:
1. `No of test patches = (\d+)`.
2. All `{...}` objects in stdout; keep those with `event==="report"` or any of `avg_de`, `avg_de2000`, `peak_de`, `peak_de2000`, `rms`, `rms_de`. Prefer object with `*de2000` keys.
3. Else text: `Profile check complete, errors…: max. = X, avg. = Y, RMS = Z`.
4. Else regex families for avg/max/rms. Missing metrics → `warnings[]` and cards show 0.00 (`AGENTS.md:2324`).
JSON schema (mock + tests):
```json
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
```
Also accepted: `peak_de` / `avg_de` / `max_de` / `rms_de`.
Quality bands (ICCery#95, **not** Argyll): `<1` Excellent, `<2` Good, `<3.5` Acceptable, else Poor. Saved via `save_verification_record`.
### 9.4 stdin / artefacts
No stdin. Consumes `.ti3` + `.icc`/`.icm`. Produces no new Argyll files (verification_history.json is ICCery). Attempts to load `{basename}.gam` into the 3D viewer (created earlier by iccgamut).
### 9.5 Tests
Rust argv: `commands.rs:21022110`. JS: `profcheck.test.js` (real `-u` JSON, de2000 preference, text summary, legacy regex, breach alert). Mock: `profcheck.mock`.
---
## 10. `iccgamut` — 3D gamut mesh
### 10.1 When
Automatically after colprof success (`colprof.js:197214`) → `extract_gamut` (`commands.rs:693731`). Process id `iccgamut_{stem}`.
### 10.2 argv — `build_iccgamut_args` (`commands.rs:684691`)
```
-v -d 10 {resolved_icc_path}
```
`-d 10` is **surface density**, not a directory (ICCery#112). Path is the real `.icc`/`.icm` (auto-flip if missing). cwd = parent of the profile.
**No `ARGYLL_3D_DISP`.** The GUI never launches Argylls 3D viewer; it reads the `.gam` file in-process.
### 10.3 Exit
`code === 0``loadGamutMesh("{cwd}/{basename}.gam")`. Failures are `console.warn` only.
### 10.4 Artefacts
Consumes ICC/ICM. Produces `{stem}.gam` next to the profile (Argyll default). Bundled reference: `src/assets/sRGB.gam` and `src-tauri/argyll/reference_gamuts/sRGB.gam`.
### 10.5 Tests
`commands.rs:14751478`.
---
## 11. Binaries that are **not** invoked
| Binary | Status |
|---|---|
| `dispwin` | Never spawned. ICCery#90 “Emissive display calibration (dispwin & dispread)” = Won't Fix |
| `dispread` | Same |
| `spotread`, `dispcal`, `collink`, `cctiff`, `spec2cie`, `illumread`, `synthacc` | Not referenced |
| Generic `spawn_process` | **Registered** (`lib.rs:55`, `commands.rs:614`) but **no JS caller**. Always `cwd=None`. Exists as an escape hatch |
---
## 12. ProcessManager internals
File: `src-tauri/src/process_manager.rs`.
### 12.1 Maps
```rust
stdins: Arc<Mutex<HashMap<String, Arc<Mutex<ChildStdin>>>>>
killers: Arc<Mutex<HashMap<String, oneshot::Sender<()>>>>
```
The `Child` itself lives only in the wait task (not in a map) so `wait()` cannot hold a mutex that `send_stdin` needs.
### 12.2 Spawn ids (complete list)
| Pattern | Binary |
|---|---|
| `targen_{basename}` | targen (profile + CAL_) |
| `printtarg_{basename}` | printtarg |
| `chartread_{basename}` | chartread |
| `average_{output}` | average (`output` includes `.ti3`) |
| `colprof_{basename}` | colprof |
| `profcheck_{ti3_path}` | profcheck (full path) |
| `iccgamut_{stem}` | iccgamut |
| `instlist` | instlist (literal) |
| caller-supplied | unused `spawn_process` |
### 12.3 Duplicate rejection (ICCery#116, `07d28eb`)
Before spawn, if `stdins.contains_key(&id)``Err("Process '{id}' is still running")`. Prevents a second `chartread_{basename}` from replacing map entries while the old wait task still `remove()`s that id on exit (which would orphan the live stdin).
Frontend also sets `measurementInProgress` to disable Start / Measure Another / Finish.
### 12.4 Wait / reap
Wait task (`process_manager.rs:166194`):
```rust
let exit_code = tokio::select! {
res = child.wait() => status.code().unwrap_or(0),
_ = kill_rx => { child.start_kill(); child.wait().await; code.unwrap_or(1) }
};
stdins.remove(id); killers.remove(id);
emit_exit(id, exit_code);
```
Natural exit also reaps, so `send_stdin` after the child dies returns “Process not found”.
v0.1.16 (`90012e4`, ICCery#63) originally awaited `child.wait()` **inside** `Mutex<Child>` — that is the bug #84 fixed.
### 12.5 stdin write
```rust
stdin.write_all(input.as_bytes()).await?;
stdin.flush().await?;
```
No encoding transform. JS is responsible for `\n`.
`kill` first `stdins.remove(id)` (drops/closes the pipe, which is what Argyll sees as EOF) then fires the oneshot so the wait task `start_kill()`s.
### 12.6 `kill_all`
`process_manager.rs:237267`:
1. `stdins.clear()` (close every pipe).
2. Drain `killers` and `send(())` each.
3. Sleep 50 ms so wait tasks can `start_kill()`.
4. Return count.
Invoked:
- Tauri command `kill_all_processes` (`commands.rs:3338`) — **no JS caller**.
- `RunEvent::Exit` / `ExitRequested` (`lib.rs:124129`).
- `WindowEvent::CloseRequested` (`lib.rs:136141`).
This is ICCery#147/#149: closing the window during chartread used to leave the instrument locked.
### 12.7 Windows pipe deadlock history
Two layers, two repos:
**ICCery#84 (host, v0.2.1, PR #83, `d6118a1`)** — Tokio `Child::wait()` deadlock:
- PR #77 / #63 replaced `try_wait` polling with `child.wait().await` while holding `Mutex<Child>`.
- Effect 1: `send_stdin` / `kill` blocked until the child exited (Calibrate/Retry/Skip/Cancel dead).
- Effect 2: Tokios `wait()` **drops stdin** to avoid parent/child pipe deadlock, so the pipe closed immediately after spawn — chartread never received keystrokes.
- Fix: `take()` all three stdio handles; store only `ChildStdin` in the map; `select!` wait vs oneshot kill.
**gronod/argyllcms#24 (child, Windows anonymous pipes)** — `ReadFile` deadlock inside Argyll:
- `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes** (`CreatePipe` / Rust `Stdio::piped()`).
- `con_char(wait=0)` then `ReadFile()`s a blocking pipe → `poll_con_char` never returns → instrument switch (USB EP `0x84`) is never polled → lamp never lights after calibration.
- Fix in the fork: `PeekNamedPipe` before `ReadFile` (`spectro/conv.c`).
- ICCery-side companion: `ARGYLL_NOT_INTERACTIVE=1` (#134) so Argyll takes the pipe path at all. **Stock Graeme Gill binaries will still hang on Windows interactive chartread.**
ROADMAP.md:59: “Resolved P0 process manager deadlock and premature stdin pipe closure affecting interactive `chartread` instrument workflows.” / “Decoupled `ChildStdin` mutex management from child process wait/reap tasks.”
### 12.8 ProcessManager tests
`process_manager.rs:270325`: occupancy helper (does not actually spawn), `kill_all` drains 2 fake killers, path sanitise. No integration test that execs a real binary.
---
## 13. Every Tauri command related to processes
From `lib.rs:54119` plus the command bodies:
### Direct process control
| Command | File:line | Role |
|---|---|---|
| `spawn_process(id, binary, args)` | `commands.rs:614` | Generic spawn, **cwd=None**, unused by UI |
| `send_stdin(id, input)` | `commands.rs:1623` | Write bytes to ChildStdin |
| `kill_process(id)` | `commands.rs:2531` | Close stdin + start_kill one id |
| `kill_all_processes()` | `commands.rs:3338` | Kill all; returns count |
| `resolve_binary(binary_name)` | `commands.rs:48106` | Path lookup |
### Argyll runners (ProcessManager)
| Command | Binary | id |
|---|---|---|
| `run_targen` | targen | `targen_{basename}` |
| `run_printtarg` | printtarg | `printtarg_{basename}` |
| `run_chartread` | chartread | `chartread_{basename}` |
| `run_average` | average | `average_{output}` |
| `run_colprof` | colprof | `colprof_{basename}` |
| `run_profcheck` | profcheck | `profcheck_{ti3_path}` |
| `extract_gamut` | iccgamut | `iccgamut_{stem}` |
| `detect_instruments` | instlist | `instlist` |
| `generate_calibration_target` | targen | `targen_{CAL_basename}` |
### Argyll runners (captured, no events)
| Command | Binary |
|---|---|
| `compute_calibration_curves` | printcal |
| `apply_calibration` | applycal |
### Artefact helpers used around those processes
`parse_ti2_header`, `snapshot_ti3`, `promote_ti3`, `verify_stage_artefacts`, `get_profile_path`, `read_tiff_preview_png`, `read_file_base64`, `select_*` dialogs, `parse_cal_file_cmd`, `list_saved_calibrations`, `save_calibration_to_library`, `select_cal_file`, `load/save_project_calibration`.
---
## 14. File artefacts by OS / stage
| Stage | Consumes | Produces | Gate |
|---|---|---|---|
| 0 cal chart | — | `CAL_*.ti1` | — |
| 0 print | `CAL_*.ti1` | `CAL_*.ti2`, `CAL_*.tif` | — |
| 0 measure | `CAL_*.ti2` | `CAL_*_passN.ti3``CAL_*.ti3` | — |
| 0 compute | `CAL_*.ti3` | `CAL_*.cal`, `iccery-calibration.json` | — |
| 1 | optional `-c` ICC | `{base}.ti1` | `stage1_complete` |
| 2 | `.ti1` | `.ti2`, `.tif`/`.tiff` pages | `stage2_complete` |
| 3 | `.ti2` | `_passN.ti3` then `.ti3` | `stage3_complete` = `.ti3` exists |
| 4 | `.ti3` | `.icc` (macOS/Linux) or `.icm` (Windows); optional in-place applycal | `stage4_complete` |
| 4b | ICC | `{stem}.gam` | not a gate |
| 5 | `.ti3` + ICC | none (history JSON is ICCery) | — |
`verify_stage_artefacts` (`commands.rs:650676`) checks existence only (no content). `.ti2` missing while `.ti3` present still reports `stage2_complete: false`.
`parse_ti2_header` (`commands.rs:366424`) reads `TARGET_INSTRUMENT`, `NUMBER_OF_FIELDS` (stored as `colorant_count` — this is CGATS field count, not ink count), `NUMBER_OF_SETS``patch_count`, `NUMBER_OF_PAGES`/`PAGES`.
---
## 15. Flag cheat-sheet (`-u`, `-Y`, `-K`, `-f`, `-c`, `-d`)
| Flag | Binary | Meaning in ICCery |
|---|---|---|
| `-u` | printtarg | JSON page manifest (always) |
| `-u` | chartread | `ROW_COLORS_JSON` per row (always) |
| `-u` | profcheck | JSON ΔE report (always) |
| `-u` | applycal | **unapply** curves (never sent from JS) |
| `-u` | targen / colprof | **not used** (fork supports JSON progress) |
| `-Y l` | chartread | i1Pro2 LED feedback (settings, default off) |
| `-K` | printtarg | apply `.cal` to printed patches (profiling only) |
| `-I` | printtarg | embed `.cal` without applying (builder supports; UI always `calibration_embed_only: false`) |
| `-I` | printcal | no ink limit (UI never sets) |
| `-f N` | targen | full-spread patch count; cal charts force `-f 0` |
| `-f VAL` | colprof | FWA/OBA (`D50`/`D65`/`.sp`/bare) |
| `-c PATH` | targen | preconditioning profile |
| `-c PORT` | chartread | comm port |
| `-c COND` | colprof | input viewing condition |
| `-d 2\|4` | targen | RGB / CMYK |
| `-d 10` | iccgamut | surface density |
| `-d STR` | printtarg | custom chart label |
| `-d COND` | colprof | output viewing condition |
---
## 16. Tests covering arg builders (index)
| Builder | Tests |
|---|---|
| `build_targen_args` | `commands.rs` `test_build_targen_args_{rgb,cmyk,total_patches_fallback,all_advanced_flags,rgb_ignores_ink_limit}` |
| `build_calibration_targen_args` | `calibration.rs` `test_build_calibration_targen_{rgb,cmyk_ink_limit,rejects_path}` |
| `build_printtarg_args` | `commands.rs` i1/A4/8bit, CM/Letter/16bit, custom page, label, seed, raster, `-K`, `-I` |
| `build_chartread_args` | auto, empty port, with port, leds on/off, port+leds |
| `build_average_args` | two-input, pass-file names |
| `build_colprof_args` | base, FWA D50/none/sp, illuminant+observer, viewing, combined |
| `build_profcheck_args` | `-v -k -s -u` + paths |
| `build_iccgamut_args` | `-v -d 10 path` |
| `build_printcal_args` | defaults, overrides+prev |
| `build_applycal_args` | apply+out, unapply+empty-err |
| `get_binary_candidates` | exe on Windows |
| JS parsers | `chartread.test.js`, `profcheck.test.js`, `calibration.test.js` |
| ProcessManager | duplicate-id occupancy, `kill_all`, path sanitise |
`npm test` runs the JS suites. Rust: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`.
+820
View File
@@ -0,0 +1,820 @@
# 05 — Gronod ArgyllCMS fork protocols
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Source tree analysed: `/tmp/argyllcms` (fork of upstream ArgyllCMS **V3.5.0**, 21 Aug 2026).
Related: `/tmp/ICCery`, `/tmp/iccery-research/argyll-issues.json`, `/tmp/iccery-research/issues/111.md`.
This document is the fork-vs-upstream delta that ICCery actually depends on. Upstream Graeme Gill ArgyllCMS 3.5.0 has **none** of the `-u` JSON streams, `instlist`, `printtarg -d`, `chartread -Y l`, or the Windows `PeekNamedPipe` stdin fix.
---
## 1. Fork identity, licence, subprocess isolation
| Field | Value |
|---|---|
| Upstream | ArgyllCMS V3.5.0 (Graeme W. Gill) |
| Fork | `gronod/argyllcms` — Gordon Bolton `<gordon@i3omb.com>` |
| Repo | https://git.i3omb.com/gronod/argyllcms |
| Version string | `ARGYLL_VERSION_STR "3.5.0"` (`h/aconfig.h`) |
| Release tags | `v3.5.0-ICCery.1.x` (e.g. 1.0, 1.2, 1.5) |
| Licence | **GNU Affero GPL v3** (`License.txt`, `ReadMe.txt`) |
`ReadMe.txt` states the fork's purpose:
> this fork adds structured JSON output capabilities (via a common `-u` switch) to several tools so they can be driven as isolated subprocesses by external UIs while preserving AGPLv3 licence isolation (no library linking; communication over stdin / stdout / stderr only).
### Why ICCery cannot link
ArgyllCMS is AGPLv3. Linking `libinst` / `libicc` / `libcgats` into the MIT/proprietary host would taint ICCery. The integration contract is:
1. Never `#include` or link Argyll libraries.
2. Spawn each tool as a child process with piped stdin/stdout/stderr.
3. Treat the binaries as an external OS utility.
4. Host parses line-oriented stdout; never shares address space.
ICCery implements this in `src-tauri/src/process_manager.rs`: `Command::new(binary)` + `Stdio::piped()` on all three FDs, plus `ARGYLL_NOT_INTERACTIVE=1`.
The fork ships a dedicated spec at `doc/chartread_integration_guide.md` covering AGPL isolation, `ROW_COLORS_JSON` framing, and host-side parsers.
---
## 2. Each tool's `-u` JSON protocol
**Prefix rule (critical):** only `chartread` prefixes. Every other tool emits **bare JSON** on stdout.
| Tool | Flag | Prefix | Framing | When | Progress vs final |
|---|---|---|---|---|---|
| `chartread` | `-u` | `ROW_COLORS_JSON: ` (space after colon) | **one compact line** + `fflush` | after each completed row (strip / XY sheet rows / whole-chart rows / per-patch in `-p`) | **progress** (one event per row); no final summary JSON |
| `instlist` | *(always)* | none | **pretty-printed multi-line** object | once at exit | **final only** |
| `printtarg` | `-u` | none | **pretty-printed multi-line** object | once after all pages written | **final only** (`event: manifest`) |
| `targen` | `-u` | none | **one compact line** per tick | during OFPS/grid generation | **progress** (`generating` / `seeding` / `optimising`) |
| `colprof` | `-u` | none | **one compact line** per tick | during A2B/B2A/gamut | **progress** (no final report JSON) |
| `profcheck` | `-u` | none | **one compact line** | after all patches scored | **final only** (`event: report`) |
ICCery's process manager only special-cases the chartread prefix:
```129:138:/tmp/ICCery/src-tauri/src/process_manager.rs
const JSON_ROW_PREFIX: &str = "ROW_COLORS_JSON: ";
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with(JSON_ROW_PREFIX) {
let json_str = line[JSON_ROW_PREFIX.len()..].to_string();
crate::events::emit_json_row(&app_clone, &id_clone, json_str);
} else {
log::info!(target: "subprocess", "[{id_clone}] {line}");
emit_stdout(&app_clone, &id_clone, line);
}
}
```
Everything else (instlist, printtarg, profcheck, colprof, targen) arrives as ordinary `process:stdout` lines. instlist is reassembled client-side and `JSON.parse`'d as a whole document.
### 2.1 `chartread -u`
Flag parser:
```2885:2887:/tmp/argyllcms/spectro/chartread.c
/* Enable UI JSON output */
} else if (argv[fa][1] == 'u') {
json_ui_out = 1;
```
Emitter (the only place `ROW_COLORS_JSON:` is written):
```240:282:/tmp/argyllcms/spectro/chartread.c
static void emit_row_json_colors(const char *row_id, int row_index, int total_rows, int patch_count, int nchan, chcol **scbs) {
int i, j;
if (!json_ui_out) return;
fprintf(stdout, "ROW_COLORS_JSON: {\"event\": \"row_complete\", \"row_id\": \"%s\", \"row_index\": %d, \"total_rows\": %d, \"patch_count\": %d, \"patches\": [", row_id ? row_id : "", row_index, total_rows, patch_count);
/* ... per-patch objects ... */
fprintf(stdout, "]}\n");
fflush(stdout);
}
```
Call sites:
| `rmode` | Mode | When emitted |
|---|---|---|
| 3 | whole-chart saved readings | one event per row after all patches transferred |
| 2 | XY table | one event per row of the just-read sheet |
| 1 | strip (handheld i1Pro etc.) | one event per successfully accepted strip, **after** bi-di reversal + DTP51 offset, patches left-to-right |
| 0 | spot / `-p` | **one event per patch**, `patch_count=1` |
Without `-u`, stdout is 100% unmodified (the early `if (!json_ui_out) return`).
### 2.2 `targen -u`
```161:167:/tmp/argyllcms/target/targen.c
int json_progress = 0;
void emit_json_progress(const char *stage, int percent) {
if (!json_progress) return;
printf("{\"event\": \"progress\", \"stage\": \"%s\", \"percent\": %d}\n", stage, percent);
fflush(stdout);
}
```
Stages observed in source:
- `"generating"` — cube / full-spread fill (`targen.c`, `ifarp.c`)
- `"seeding"` — OFPS seed (`ofps.c`)
- `"optimising"` — OFPS / display-delay iteration (`ofps.c`, `targen.c`); last tick is `percent: 100`
ICCery **does not currently pass `-u` to targen** (`build_targen_args` in `commands.rs`). Progress is inferred from verbose text.
### 2.3 `printtarg -u`
Final-only, pretty-printed, **no prefix**:
```2949:2968:/tmp/argyllcms/target/printtarg.c
if (json_manifest) {
int pi;
printf("{\n");
printf(" \"event\": \"manifest\",\n");
printf(" \"pages\": [\n");
for (pi = 0; pi < npages; pi++) {
printf(" {\"filename\": \"%s\", \"patches\": %d, \"width_mm\": %g, \"height_mm\": %g}%s\n",
page_filenames[pi] != NULL ? page_filenames[pi] : "",
page_patches[pi],
pw,
ph,
(pi < npages - 1) ? "," : "");
if (page_filenames[pi] != NULL)
free(page_filenames[pi]);
}
printf(" ]\n");
printf("}\n");
fflush(stdout);
```
Schema:
```json
{
"event": "manifest",
"pages": [
{"filename": "target.tif", "patches": 800, "width_mm": 210, "height_mm": 297},
{"filename": "target_02.tif", "patches": 250, "width_mm": 210, "height_mm": 297}
]
}
```
- `filename` is the basename actually written (`%s.tif` if one page, `%s_%02d.tif` if more).
- `width_mm` / `height_mm` are paper size in millimetres (`pw`/`ph`).
- `patches` is a running count of TID/real patches assigned to that page (includes some padding/TID cells — not strictly "user patches only").
- ICCery **does** pass `-u` (`build_printtarg_args`).
### 2.4 `colprof -u`
Same compact progress line as targen:
```78:84:/tmp/argyllcms/profile/colprof.c
int json_progress = 0;
void emit_json_progress(const char *stage, int percent) {
if (!json_progress) return;
printf("{\"event\": \"progress\", \"stage\": \"%s\", \"percent\": %d}\n", stage, percent);
fflush(stdout);
}
```
Stages: `"gamut_mapping"` (10/25/40/75/100), `"a2b_table"`, `"b2a_table"`, `"gamut_table"`. Percent ticks also fire from LUT fill callbacks in `profout.c` / `profin.c`.
**Flag collision with upstream `-u`.** Parser (`colprof.c:503-521`):
- `-ua` / `-uc` / `-u <number>` → original input-profile white-point flags
- `-u` with no extra token → `json_progress = 1`
For ICCery's **output/printer** profiles this is safe (bare `-u` means JSON). For scanner/camera input profiles it is **not** drop-in. ICCery currently **does not pass `-u` to colprof**; `colprof.mock` emits only human text.
### 2.5 `profcheck -u`
```1256:1268:/tmp/argyllcms/profile/profcheck.c
if (json_report) {
if (cie2k) {
printf("{\"event\": \"report\", \"peak_de2000\": %.2f, \"avg_de2000\": %.2f, \"rms\": %.2f}\n",
merr, aerr/nsamps, sqrt(rerr/nsamps));
} else if (cie94) {
printf("{\"event\": \"report\", \"peak_de94\": %.2f, \"avg_de94\": %.2f, \"rms\": %.2f}\n",
merr, aerr/nsamps, sqrt(rerr/nsamps));
} else {
printf("{\"event\": \"report\", \"peak_de\": %.2f, \"avg_de\": %.2f, \"rms\": %.2f}\n",
merr, aerr/nsamps, sqrt(rerr/nsamps));
}
fflush(stdout);
}
```
Field names change with `-k` (CIEDE2000, ICCery default) vs `-c` (CIE94) vs default CIE76. ICCery always sends `-v -k -s -u`. The human line `Profile check complete, errors(CIEDE2000): ...` is still printed unless `-v` is off **and** `-u` is on.
`profcheck.mock` emits exactly:
```
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
```
---
## 3. `chartread` `ROW_COLORS_JSON` schema (field-by-field)
Exact prefix: **`ROW_COLORS_JSON: `** (17 chars including the trailing space). Payload is compact JSON, no newlines inside. Host must strip the prefix then `JSON.parse`.
### Top-level
| Field | Type | Source | Notes |
|---|---|---|---|
| `event` | string | literal | always `"row_complete"` |
| `row_id` | string | `paix->aix(paix, row_index)` | strip letter, e.g. `"A"`; `""` if NULL |
| `row_index` | int | 0-based overall row | |
| `total_rows` | int | `totpa` | total passes in the chart |
| `patch_count` | int | `stipa` (or `1` in `-p`) | length of `patches[]` |
| `patches` | array | | left-to-right after bi-di reversal |
### Patch object
| Field | Type | Source | Notes |
|---|---|---|---|
| `id` | string | `scb->id` | `"0"` ⇒ spacer |
| `loc` | string | `scb->loc` | e.g. `"A1"` |
| `is_pad` | bool | `strcmp(scb->id, "0") == 0` | JSON `true`/`false` (unquoted) |
| `device` | `[float]` | `scb->dev[j] * 100.0` | `%.4f`, **0100%**, length = `nchan` (RGB=3, CMYK=4) |
| `expected` | object, **optional** | omitted if `eXYZ` is all zeros | present only when `.ti2` has a reference |
| `expected.XYZ` | `[3]` | `scb->eXYZ` | scale **0100** |
| `expected.Lab` | `[3]` | `icmXYZ2Lab(&icmD50, …, eXYZ/100)` | D50 |
| `measured` | object | **always present** | even for pads |
| `measured.XYZ` | `[3]` | `scb->XYZ` | scale **0100** |
| `measured.Lab` | `[3]` | `icmXYZ2Lab(&icmD50, …, XYZ/100)` | D50; L* ~0100 |
| `measured.spectral` | object, **optional** | omitted if `sp.spec_n == 0` | omitted by `-n` or colorimeter |
| `measured.spectral.bands` | int | `sp.spec_n` | |
| `measured.spectral.start_nm` | float `%.1f` | `sp.spec_wl_short` | typically 380 |
| `measured.spectral.end_nm` | float `%.1f` | `sp.spec_wl_long` | typically 730 |
| `measured.spectral.norm` | float `%.1f` | `sp.norm` | typically 100.0 |
| `measured.spectral.values` | `[float]` | `sp.spec[j]` `%.4f` | length = `bands` |
Lab conversion (XYZ must be divided by 100 before `icmXYZ2Lab`):
```218:237:/tmp/argyllcms/spectro/chartread.c
static void compute_patch_metrics(chcol *scb, double *eLab, double *mLab) {
if (scb->eXYZ[0] != 0.0 || scb->eXYZ[1] != 0.0 || scb->eXYZ[2] != 0.0) {
double scaled_exyz[3];
scaled_exyz[0] = scb->eXYZ[0] / 100.0;
/* ... */
icmXYZ2Lab(&icmD50, eLab, scaled_exyz);
} else {
eLab[0] = eLab[1] = eLab[2] = 0.0;
}
{
double scaled_mxyz[3];
scaled_mxyz[0] = scb->XYZ[0] / 100.0;
/* ... */
icmXYZ2Lab(&icmD50, mLab, scaled_mxyz);
}
}
```
Worked example (from `chartread.mock`, which matches the C serializer):
```
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "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]}]}
```
Pad patches still have `device` + `measured`; they usually lack `expected`. UIs must skip `is_pad==true` for ΔE stats (ICCery `swatch_grid.js` does).
---
## 4. `instlist` JSON device enumeration (ICCery bug #111)
New binary `spectro/instlist.c`. Always JSON; no `-u`. Usage: `instlist [-v] [-D [level]]`.
```90:135:/tmp/argyllcms/spectro/instlist.c
if ((icmps = new_icompaths(g_log)) == NULL) {
printf("{\n \"event\": \"instruments\",\n \"devices\": []\n}\n");
fflush(stdout);
return 1;
}
paths = icmps->paths;
printf("{\n \"event\": \"instruments\",\n \"devices\": [\n");
if (paths != NULL) {
for (i = 0; paths[i] != NULL; i++) {
char *tname = inst_name(paths[i]->dtype);
char *name = paths[i]->name ? paths[i]->name : "Unknown";
/* ... */
printf(" \"port\": %d,\n", i + 1);
printf(" \"name\": \""); /* escaped " and \ */
printf("\",\n");
printf(" \"type\": \"%s\"\n", (tname != NULL && tname[0] != '\000') ? tname : "Unknown");
printf(" }");
count++;
}
}
printf("\n ]\n}\n");
fflush(stdout);
```
Schema (from `doc/instlist.html` + source):
```json
{
"event": "instruments",
"devices": [
{
"port": 1,
"name": "usb:/bus0/dev1 (X-Rite i1Pro)",
"type": "i1Pro"
}
]
}
```
| Field | Type | Meaning |
|---|---|---|
| `port` | int, **1-based** | `i+1` over `icompaths->paths[]`**the same integer as `chartread -c N`** |
| `name` | string | `icompath->name` (USB path + product string). `"` and `\` escaped. |
| `type` | string | `inst_name(dtype)` e.g. `"i1Pro"`, `"ColorMunki"`, `"SpectroScan"` |
Empty enumeration (no USB perms / no devices / `new_icompaths` fail) still emits `{"event":"instruments","devices":[]}` (exit 1 only if `new_icompaths` itself fails).
### Bug #111 — ports vs device index
ICCery originally treated instlist's ordinal as `chartread -c`. That is **correct in this fork** *if* you use `devices[i].port` (which **is** the comm-port index). It is **wrong** if you:
- use 0-based array index,
- parse `chartread -??` separately (different listing / extra serial junk),
- or pass `-c 1` when the UI meant "auto".
ICCery's current fix (`chartread.js`):
- JSON path: `opt.value = inst.port && inst.port !== "1" ? inst.port : ""`
- empty value → `build_chartread_args` **omits `-c`** (Argyll default port 1)
- regex fallback requires a known instrument token or `on '…'` clause
`doc/instlist.html` explicitly: *The `port` number directly corresponds to the `-c` parameter accepted by chartread, spotread, and dispread.*
---
## 5. `chartread -Y l` LED protocol / colours
**Not `-L`.** Issue #37 proposed `-L`; the implementation is **`-Y l`** (also accepts `-Y L`).
```2943:2953:/tmp/argyllcms/spectro/chartread.c
else if (argv[fa][1] == 'Y') {
fa = nfa;
if (na == NULL)
usage();
if (na[0] == 'l' || na[0] == 'L') {
g_use_leds = 1;
} else {
usage();
}
}
```
ICCery: `enable_i1pro2_leds``["-Y", "l"]`. Usage text: `-Y l Enable i1Pro 2 visual LED feedback`. **Not documented in `doc/chartread.html` yet** (only `usage()`).
Dispatch is capability-silent: missing `set_led_state` or non-i1Pro2 returns `inst_unsupported` and is ignored.
```207:216:/tmp/argyllcms/spectro/chartread.c
static int g_use_leds = 0;
static void update_led_state(inst *it, inst_led_state state) {
if (!g_use_leds || it == NULL)
return;
if (it->set_led_state != NULL) {
it->set_led_state(it, state);
}
}
```
Enum (`spectro/inst.h`):
```c
typedef enum {
inst_led_off = 0,
inst_led_cal_wait = 1, /* Flashing White */
inst_led_row_ready = 2, /* Flashing Blue */
inst_led_row_fail = 3, /* Flashing Red */
inst_led_row_success = 4 /* Solid/Flash Green */
} inst_led_state;
```
Hardware: **X-Rite i1Pro 2 (Rev E) only** (`p->dtype != instI1Pro2``inst_unsupported`). Default `inst.c` stub returns `inst_unsupported`. Implementation is a background thread in `i1pro_imp.c` driving `i1pro2_indLEDset` bitmasks:
| State | Visual | Timing | Mask |
|---|---|---|---|
| `inst_led_cal_wait` | White flash | 500 ms on / 500 ms off | `0x3F` (L+R R+G+B) |
| `inst_led_row_ready` | Blue pulse | 300 ms on / 700 ms off | `0x24` (L+R Blue) |
| `inst_led_row_fail` | Red strobe | 3× (100 ms on / 100 ms off), then auto-off | `0x09` (L+R Red) |
| `inst_led_row_success` | Green confirm | 400 ms solid, then auto-off | `0x12` (L+R Green) |
| `inst_led_off` | Off | — | `i1pro2_indLEDoff` |
Lifecycle hooks in `chartread.c`:
- before `inst_handle_calibrate``cal_wait`; after → `off`
- before `read_strip` / `read_sample` wait → `row_ready`
- misread / coms fail / unexpected error → `row_fail`
- accepted strip → `row_success`
- abort / session end → `off`
---
## 6. `printtarg -d` custom label
Issue #19. `-d` was unused (TIFF 8-bit dither is `-D`).
```3342:3348:/tmp/argyllcms/target/printtarg.c
else if (argv[fa][1] == 'd') {
fa = nfa;
if (na == NULL) usage("Expected argument to -d");
custom_label = na;
custom_label_set = 1;
}
```
```3836:3847:/tmp/argyllcms/target/printtarg.c
if (custom_label_set) {
if (custom_label[0] != '\000') {
strncpy(label, custom_label, sizeof(label) - 1);
label[sizeof(label) - 1] = '\000';
ocg->add_kword(ocg, 0, "CHART_LABEL", custom_label, NULL);
} else {
label[0] = '\000';
}
} else {
sprintf(label, "ArgyllCMS - Chart \"%s\" (%s %d) %s",
psname, rand ? "Random Start" : "Chart ID", rstart, atm);
}
```
And inside layout:
```2299:2300:/tmp/argyllcms/target/printtarg.c
if (label == NULL || label[0] == '\000')
dopglabel = 0; /* Omit per-page labelling */
```
Behaviour:
| Invocation | Border text | `.ti2` keyword |
|---|---|---|
| no `-d` | `ArgyllCMS - Chart "<psname>" (Chart ID\|Random Start N) <datetime>` | none (`CHART_ID` / `RANDOM_START` still written) |
| `-d "My Media 1440dpi"` | that string (truncated to 1023 chars) | `CHART_LABEL` |
| `-d ""` | omitted (`dopglabel=0`) | not written |
ICCery always passes `-d` when `custom_label` is `Some(...)` (test: `"ICCery - Pro900 - Luster - 29/08/2026 12:00"`).
---
## 7. Windows anonymous-pipe stdin deadlock (argyllcms #24)
Symptom: ICCery/Tauri on Windows, `chartread` as child with redirected stdin. Calibration works; holding the i1Pro button never lights the lamp. Process stuck in stdin poll.
Root cause (upstream `ARGYLL_NOT_INTERACTIVE`):
1. `numlib/numsup.c` `check_if_not_interactive()` calls `SetNamedPipeHandleState(PIPE_NOWAIT)` on stdin.
2. That **fails on Win32 anonymous pipes** (`CreatePipe` / Rust `Stdio::piped()`). Pipe stays blocking.
3. `spectro/conv.c` `con_char(wait=0)` then `ReadFile()`'s the pipe, **blocks forever**.
4. `def_uicallback``poll_con_char``con_char(0)` runs from `i1pro_imp_measure()`'s UI callback, so USB EP `0x84` switch polling never resumes.
Fork fix in `spectro/conv.c`: `PeekNamedPipe` before `ReadFile`:
```203:237:/tmp/argyllcms/spectro/conv.c
} else if (stdin_type == FILE_TYPE_PIPE) {
int i, bib;
DWORD bytes_avail = 0;
if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
if (!wait) {
return 0;
}
}
for (bib = 0; bib < 10;) {
if (!wait) {
if (!PeekNamedPipe(stdinh, NULL, 0, NULL, &bytes_avail, NULL) || bytes_avail == 0) {
break;
}
}
if ((!ReadFile(stdinh, buf + bib, 10 - bib, &bread, NULL) || bread == 0)
&& !wait) {
break;
}
/* ... wait for \n / \r / ^C ... */
}
rv = buf[0];
```
`SetNamedPipeHandleState` is still attempted (harmless failure). `PeekNamedPipe` is valid on both anonymous and named pipes.
Shipped in `v3.5.0-ICCery.1.2`. ICCery always sets `ARGYLL_NOT_INTERACTIVE=1` and `CREATE_NO_WINDOW` on Windows, so this fix is load-bearing.
Doc (`doc/Environment.html`): *On MSWin systems, the character and return or line feed characters must be written to stdin in a single operation.* ICCery honours this (`" \n"`, `"d\n"`, `"q\n"`).
---
## 8. USB drivers packaging
Windows release zip must contain `usb/` (issue #21: CRLF in `binfiles` / `doc/afiles` / `usb/binfiles.msw` made `cp` miss every manifest entry). Fork sanitises with `tr -d '\r'` in `makepackagebin.sh` and copies missing `ArgyllCMS_{x64,arm64}.cat` from `ArgyllCMS.cat`.
`usb/binfiles.msw`:
```
ArgyllCMS_install_USB.exe
ArgyllCMS_uninstall_USB.exe
ArgyllCMS.cat
ArgyllCMS_x64.cat
ArgyllCMS_arm64.cat
ArgyllCMS.inf
bin/libusb-win32-bin-README.txt
bin/x86/libusb0.sys
bin/amd64/libusb0.sys
bin/arm64/libusb0.sys
```
ICCery `scripts/fetch-argyll.mjs` copies `usb/` from the Windows archive into `src-tauri/argyll/usb/` and **fails the fetch** if `ArgyllCMS_install_USB.exe` or `ArgyllCMS.inf` is missing.
NSIS (`src-tauri/windows/hooks.nsh`): admin install prompts "Install ArgyllCMS USB instrument drivers?" then `ExecWait ArgyllCMS_install_USB.exe`. Uninstall does **not** auto-run `ArgyllCMS_uninstall_USB.exe` (would break other Argyll apps).
Linux: udev rules live in the Argyll tarball `usb/` (`binfiles.lx`); ICCery does not currently stage them (USB access is via user-installed udev / plugdev).
---
## 9. macOS ad-hoc signing of Mach-O sidecars
Issue #32, release `v3.5.0-ICCery.1.5` (`1eb72e8`, `cf93305`).
`makepackagebin.sh`:
```176:185:/tmp/argyllcms/makepackagebin.sh
# Apply ad-hoc code signatures to macOS Mach-O binaries before staging
if [ "${OSTYPE#*darwin*}" != "$OSTYPE" ] ; then
echo "=== Applying ad-hoc code signatures to macOS Mach-O binaries ==="
for f in bin/* ; do
if [ -f "$f" ] && file "$f" | grep -q "Mach-O" ; then
echo "Signing $f..."
codesign -f -s - "$f" || true
fi
done
fi
```
Also signs `lipo` universal (`x86_64` + `arm64`) binaries (issue #27: `Argyll_V*_macOS_universal_bin.tgz`). CI verifies with `codesign -dvv`.
ICCery prefers `argyll/macos-universal/instlist` when present (`commands.rs` / `build.rs`); otherwise `macos-aarch64` / `macos-x86_64`. Unsigned Mach-O sidecars are killed by Gatekeeper / `killed: 9` on Apple Silicon.
---
## 10. Which binaries ICCery actually ships vs uses
`fetch-argyll.mjs` copies the **entire** `bin/` of the Gronod release (full Argyll suite: `dispcal`, `dispread`, `spotread`, `collink`, `cctiff`, `oeminst`, … plus fork `instlist`). Marker binary: `instlist` / `instlist.exe`. Plus `License.txt`. Plus Windows `usb/`.
**Invoked by ICCery** (`resolve_binary` call sites):
| Binary | Stage / feature |
|---|---|
| `instlist` | instrument dropdown |
| `targen` | Stage 1 + calibration charts |
| `printtarg` | Stage 2 |
| `chartread` | Stage 3 |
| `average` | multi-pass `.ti3` merge |
| `colprof` | Stage 4 |
| `profcheck` | Stage 4 QA |
| `iccgamut` | gamut viewer |
| `printcal` | Stage 0 linearization (#224) |
| `applycal` | embed/apply `.cal` into ICC |
The rest of the suite is on disk as AGPL corresponding source/binary distribution but unused.
Mocks used by tests (`src-tauri/argyll/mocks/`): `chartread.mock`, `colprof.mock`, `profcheck.mock` only. `chartread.mock` speaks `ROW_COLORS_JSON`; `profcheck.mock` speaks bare `{"event":"report",...}`; `colprof.mock` does **not** speak JSON.
---
## 11. Environment variables the fork / tools honour
### Fork-critical (ICCery always sets)
| Var | Set by ICCery | Effect |
|---|---|---|
| `ARGYLL_NOT_INTERACTIVE` | `"1"` in `process_manager.rs` and `calibration.rs` | LF instead of CR on progress; stdin is "char + return" not raw key; Windows pipe NOWAIT + (fork) PeekNamedPipe; unbuffered/line-buffered stdout |
### Upstream vars still live in this tree (host may set)
| Var | Consumer |
|---|---|
| `ARGYLL_3D_DISP_FORMAT` | `VRML` / `X3D` / `X3DOM` |
| `ARGYLL_COLMTER_CAL_SPEC_SET` / `ARGYLL_COLMTER_COR_MATRIX` | default CCSS/CCMX (`-X`) |
| `ARGYLL_MIN_DISPLAY_UPDATE_DELAY_MS` | display tools |
| `ARGYLL_DISPLAY_SETTLE_TIME_MULT` | display tools |
| `ARGYLL_DISPLAY_FAKE_RAND_SEED` | `dispsup.c` |
| `ARGYLL_CREATE_WRONG_VON_KRIES_OUTPUT_CLASS_REL_WP` | ICC writer |
| `ARGYLL_CREATE_DISPLAY_PROFILE_WITH_CHAD` / `_WITHOUT_CHAD` | ICC writer |
| `ARGYLL_CREATE_OUTPUT_PROFILE_WITH_CHAD` | ICC writer |
| `ARGYLL_CREATE_V2COLORANT_TABLE` | ICC writer |
| `ARGYLL_PLATFORM_OVERRIDE` | `icc.c` |
| `ARGYLL_CCAST_DEFAULT_RECEIVER` / `ARGYLL_CCAST_TEST_PATTERN` | Chromecast |
| `ARGYLL_IGNORE_XRANDR1_2` / `ARGYLL_IGNORE_XINERAMA` | Linux display |
| `ARGYLL_USE_COLORD` | Linux profile store |
| `ARGYLL_DISABLE_I1PRO2_DRIVER` | force i1Pro2 legacy |
| `ARGYLL_EXCLUDE_SERIAL_SCAN` | skip COM/tty fast-scan |
| `ARGYLL_XRGA` | `XRGA` / `XRDI` / `GMDI` reflective conversion |
| `ARGYLL_XCALSTD` | per-driver X-Rite cal standard (i1pro, munki, dtp*, ss) |
| `ARGYLL_UNTWIST_GAMUT_SURFACE` | B2A / collink clip |
| `ARGYLL_REV_CACHE_MULT` / `ARGYLL_REV_ACC_GRID_RES_MULT` | rspl invert |
| `ARGYLL_SUPPRESS_PLOT` | `plot.c` |
| `XDG_{DATA,CONFIG,CACHE}_{HOME,DIRS}` | Linux paths |
| `SPYD2024_LOWLEV_MEASURE` | Spyder 2024 |
| `I1D3_DISABLE_AIO` | i1d3 (upstream) |
| `DISPLAY` / `SUDO_UID` / `SUDO_GID` | X11 / privilege drop |
ICCery does not currently set any of these besides `ARGYLL_NOT_INTERACTIVE`.
---
## 12. `chartread` interactive stdin commands and UI classifier
### 12.1 Real fork `chartread` (strip mode, `rmode==1`)
UIH table (`chartread.c:1547-1561`):
```c
inst_set_uih(0x00, 0xff, DUIH_TRIG); /* every other key starts a read */
inst_set_uih('f'/'F'/'b'/'B'/'n'/'N'/'g'/'G'/'d'/'D', DUIH_CMND);
inst_set_uih('q'/'Q'/^C/Esc, DUIH_ABORT);
```
| Key | Class | Action |
|---|---|---|
| **any other key**, Space, Return, instrument switch | TRIG | start strip read |
| `f` / `F` | CMND | next row / +10 (spot: F=+10) |
| `b` / `B` | CMND | previous row / 10 |
| `n` / `N` | CMND | next **unread** row |
| `g` / `G` | CMND | goto (spot mode; registered in strip too) |
| `d` / `D` | CMND | done & save `.ti3`. If unread patches remain → confirm `[y/n]` |
| `q` / `Q` / Esc / `^C` | ABORT | quit without saving (retry prompt first) |
| `y` / `Y` | confirm | "Are you sure [y/n]" (done-with-unread, abort) |
| `n` (at `[y/n]`) | confirm | stay in session |
| `s` / `S` | **optional calibration only** | `inst_handle_calibrate`: "or S to skip" when `inst_calc_optional_flag` |
| `k` | CMND (spot only) | force calibrate |
**There is no strip-mode `s` = skip row, and no `u` = undo.** Those exist only in ICCery's mock / UI. Sending `s` or `u` during "Ready to read strip" is **DUIH_TRIG** → starts a measurement.
### 12.2 Spot mode (`-p`) extra keys
`f/F/b/B/n/N/g/G/d/D/k` as above; Return/Space/`0` = take reading.
### 12.3 XY table (`rmode==2`)
Return = continue; `q`/Esc/`^C` = give up (parks head if `q` sent before kill — ICCery does this).
### 12.4 Exact prompt strings the C code prints
(With `ARGYLL_NOT_INTERACTIVE=1`, prompt lines still have **no trailing LF** unless noted; they are flushed via `do_fflush()`. ICCery's line reader may not see a prompt until a later `\n`.)
**Calibration** (`instappsup.c`) — real i1Pro:
```
Place the instrument on its reflective white reference S/N <id>,
and then hit any key to continue,
or hit Esc or Q to abort:
```
(optional cal appends `or S to skip`)
Other cal variants: `"Do a reflective white calibration,"`, `"Place the instrument on light trap..."`, `"Click the instrument on its reflective white reference..."`, `"Hit any key to retry, or Esc or Q to abort:"`.
**Strip ready:**
```
Ready to read strip pass <row>[ (!! ALL ROWS READ !!)| (This row has been read)]
Press 'f' to move forward, 'b' to move back, 'n' for next unread,
'd' when done, Esc or 'q' to quit without saving.
Trigger instrument switch to start reading. # uswitch==1
Trigger instrument switch or any other key to start: # uswitch==2
Press any other key to start: # uswitch==0
```
**Success / fail:**
```
Strip read OK
(Warning) Seem to have read strip pass <X> rather than <Y>!
Hit Return to use it anyway, any other key to retry, Esc or 'q' to give up:
There is at least one patch with an very unexpected response! (DeltaE <n>)
Hit Return to use it anyway, any other key to retry, Esc or 'q' to give up:
Strip read failed due to misread (<reason>)
Hit Esc to give up, any other key to retry:
Strip read failed due to communication problem.
Hit Esc or 'q' to give up, any other key to retry:
Done ? - At least one unread patch (<id>, <loc>), Are you sure [y/n]:
```
**XY table:**
```
Please place sheet <n> of <N> on table, then
hit return to continue, Esc or 'q' to give up
Please remove previous sheet, then place sheet <n> of <N> on table, then
hit return to continue, Esc or 'q' to give up
Using the XY table controls, locate patch <id> with the sight,
then hit return to continue, Esc or 'q' to give up
Sheet <n> of <N> read OK
Please remove last sheet from table
```
**Done / abort:**
```
Chart read OK
Abort ? - Are you sure ? [y/n]:
```
### 12.5 What ICCery actually sends (`chartread.js`)
| UI button | stdin bytes | Intended |
|---|---|---|
| Calibrate / Retry | `" \n"` | space + NL (single WriteFile) |
| Accept / Continue | `"\n"` | Return (use-it-anyway, XY continue) |
| Done & Save | `"d\n"` | done |
| Undo | `"u\n"` | **not a chartread command** — will TRIG a read |
| Skip | `"s\n"` | **only skips optional cal**; otherwise TRIG a read |
| Cancel (XY) | `"q\n"` then kill after 500 ms | park head |
| Cancel (strip) | kill process | |
### 12.6 `classifyChartreadLine` (tests in `chartread.test.js`)
Priority order:
1. `"remove last sheet from table"` → info, no state change (`isRemoveSheetNotice`)
2. `/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i``sheetOk`
3. `"locate patch … with … sight"``TABLE_ALIGN` (`meta.patch`)
4. `"place sheet N of M"` / `"remove previous sheet"``TABLE_PLACE_SHEET`
5. `"hit return to continue"` (sticky if already TABLE_*) → `PROMPT_CONTINUE` otherwise
6. `"'d' if/when done"`, `"all strips/patches read"`, `"d to finish/save"``ALL_STRIPS_READ`
7. `"(warning)"`, `"use it anyway"`, `"seem to have read strip pass"`, `"unexpected response"``WARNING`
8. place + (reference|white|calibrat|standard) / `"hit any key to continue"` / `"calibration"``CALIBRATING`
9. hit+read+strip / `"ready to read"``AWAITING_STRIP`
10. `"reading strip"` / `"processing"` / `"scanning"` / `"reading sheet"``READING`
11. `"error"` / `"too fast"` / `"too slow"` / `"misread"` / `"failed to read"``ERROR`
### 12.7 Mock vs real (tests rely on mock phrasing)
`chartread.mock` (and classifier tests) use **synthetic** lines that real chartread never prints:
| Mock / test line | Real chartread |
|---|---|
| `Place instrument on calibration tile and hit [Space] to calibrate.` | `Place the instrument on its reflective white reference S/N …` + `hit any key to continue` |
| `Hit [Space] to read strip A (or 's' to skip).` | `Ready to read strip pass A` + f/b/n/d menu |
| `Reading strip A...` | no such line; success is ` Strip read OK` |
| `Calibration successful.` | no such line |
| `Ready to read... done.` | `Chart read OK` |
Classifier still matches real prompts because of substring rules (`"ready to read"`, `"hit any key to continue"`, `"use it anyway"`, `"place sheet"`, `"locate patch"`). Rewrite UIs should classify the **C strings above**, not only the mock.
---
## Appendix A — Fork vs upstream 3.5.0 checklist
| Feature | Upstream 3.5.0 | Gronod fork |
|---|---|---|
| `chartread -u` / `ROW_COLORS_JSON` | no | yes |
| `instlist` | no | yes |
| `printtarg -u` JSON manifest | no | yes |
| `printtarg -d` custom label | no | yes |
| `targen -u` progress JSON | no | yes |
| `colprof -u` progress JSON (bare `-u`) | `-u` is WP-scale for **input** profiles | same, plus bare `-u` → JSON |
| `profcheck -u` ΔE JSON | no | yes |
| `chartread -Y l` i1Pro2 LEDs | no | yes |
| Windows `PeekNamedPipe` stdin | no (deadlocks on anon pipes) | yes |
| `makepackagebin.sh` CRLF sanitize + cat copies | no | yes |
| macOS `codesign -s -` + universal lipo archive | no | yes |
| `doc/chartread_integration_guide.md` | no | yes |
| `doc/instlist.html` | no | yes |
## Appendix B — ICCery `-u` usage vs fork capability
| Tool | Fork `-u` | ICCery currently passes `-u` |
|---|---|---|
| chartread | yes | **yes** (`-v -u [-c port] [-Y l] basename`) |
| printtarg | yes | **yes** (`-v -u …`) |
| profcheck | yes | **yes** (`-v -k -s -u`) |
| targen | yes | **no** |
| colprof | yes | **no** |
| instlist | always JSON | spawned with no args |
## Appendix C — Issues mapped
| Repo | # | Topic |
|---|---|---|
| argyllcms | 1 | chartread `-u` ROW_COLORS_JSON |
| argyllcms | 2 | targen `-u` |
| argyllcms | 3 | printtarg `-u` |
| argyllcms | 4 | colprof `-u` |
| argyllcms | 5 | profcheck `-u` |
| argyllcms | 6 | instlist |
| argyllcms | 19 | printtarg `-d` |
| argyllcms | 21 | Windows zip missing usb/doc (CRLF) |
| argyllcms | 24 | PeekNamedPipe deadlock |
| argyllcms | 27 | macOS universal lipo |
| argyllcms | 32 | ad-hoc codesign |
| argyllcms | 37 | i1Pro2 LEDs (spec said `-L`, code is `-Y l`) |
| ICCery | 111 | instlist port vs `-c` index |
+48
View File
@@ -0,0 +1,48 @@
# 06 — Wizard, artefact gating, resume
## Stages
| # | DOM | Unlocked when |
|---|-----|----------------|
| 0 | `#stage-cal` | Always (optional, not in stepper) |
| 1 | `#stage-1` | Always |
| 2 | `#stage-2` | `{basename}.ti1` exists |
| 3 | `#stage-3` | `.ti1` **and** `.ti2` |
| 4 | `#stage-4` | `.ti3` exists (imported datasets may skip 12) |
| 5 | `#stage-5` | `.ti3` **and** `.icc`/`.icm` |
`verify_stage_artefacts(cwd, basename)` returns `{ stage1_complete, stage2_complete, stage3_complete, stage4_complete, profile_path }`.
Stage 4 is **not** unlocked by `.ti2`. Multi-pass averaging **deletes** the canonical `.ti3` after each snapshot (`snapshot_ti3`) so Stage 4 stays locked until Finish (#109, #110).
## `wizardState` fields
| Field | Role |
|-------|------|
| `currentStage` | 05 |
| `basename` | run name without extension |
| `cwd` | working directory |
| `printerName` | last spooled printer (drift history) |
| `sessionMode` | `"profile"` (extensible) |
| `profileBasename` | may differ after import |
| `noticeTimer` | banner auto-hide |
`setTarget(basename, cwd)` updates gating. `navigateToStage(n)` refuses locked stages with a warning banner. Window `focus` re-runs `updateGating` (user may have deleted files in Finder/Explorer) (#151).
## Resume from existing target (#140)
Stage 1 **Open Existing** (`select_existing_target`) filters `.ti1`/`.ti2`. `parse_ti2_header` reads `TARGET_INSTRUMENT`, `NUMBER_OF_SETS`, `NUMBER_OF_PAGES`, sibling `.ti1` presence. If `.ti2` exists, Stage 3 banner shows "Resumed from .ti2".
CGATS import (#94, #211) can jump to Stage 4/5 with a synthesised `.ti3` — must use an **open** dialog, never save (#211).
## Empty cwd (#59)
Never spawn with `cwd: ""`. Initialize from `get_default_working_dir` (Documents → Home → app data). Disable Generate until basename **and** directory are set. Display path in `#selectedPathDisplay`.
## Profile extension (#69)
`resolve_profile_extension`: if only `.icm` exists, use it; else if `.icc` exists, use it; else default `.icm` on Windows, `.icc` elsewhere. `profcheck` and `iccgamut` also swap extension if the requested path is missing.
## No `"test_target"` fallbacks (#60)
A rewrite must not invent default basenames. Later stages stay inert until `wizardState.basename` is set from a real artefact.
+62
View File
@@ -0,0 +1,62 @@
# 07 — Stage 0: printer calibration (`printcal` / `applycal`) (#224)
Optional dashboard opened by **Calibrate Printer** (`#btnCalibratePrinter`). The 15 wizard is unchanged when skipped.
## Why `CAL_` prefix
Calibration charts use basename `CAL_<run>` so they never collide with profiling `.ti1/.ti2/.ti3`. `printtarg -K file.cal` is applied only to **profiling** layouts, never to the calibration chart itself.
## Flow
1. **Generate Calibration Target**`targen` via `build_calibration_targen_args`
2. **Create Layout & Print** → existing Stage 2 printtarg + native print (same unmanaged path)
3. **Measure Chart** → existing Stage 3 chartread
4. **Compute Curves**`printcal` (captured, not streamed)
5. Toggle **Apply Calibration** → subsequent profiling `printtarg -K` and post-`colprof` `applycal -a`
## `targen` for calibration wedges
```
-v -d {2|4} -s {steps} -g {steps} [-n {steps}] -e {white|4} [-l {tac}] -f 0 CAL_<basename>
```
- RGB: `-d 2`; CMYK: `-d 4`
- Steps clamped 1151, default 21 (`#calSteps`)
- Neutral emphasis: `-n` same as steps
- CMYK ink-limit exploration: `-l` 200400 (`#calInkExplore`, default 320)
- `-f 0` — no full-spread patches (wedges only)
- Default 4 white patches if unspecified
## `printcal`
```
-v -e [-I] [-z] [-a previous.cal] [-m TAC] [-xC pct]… -o out.cal CAL_basename
```
| Flag | Meaning |
|------|---------|
| `-e` | even (always passed) |
| `-I` | no ink limit |
| `-z` | verify |
| `-a` | previous `.cal` |
| `-m` | total ink limit |
| `-xC` / `-xM` / … | per-channel limit |
| `-o` | output `.cal` |
`.cal` overwrite requires Overwrite / Rename / Cancel (`#calCollisionDialog`). Never silently clobber.
## `applycal` (after Stage 4)
```
-v -a <cal> <input.icc> [output]
```
JS never sends `-u` (unapply). Fork `-u` on applycal is **not** JSON.
## Staleness
Warn if loaded `.cal` is older than `calibration_stale_days` (default 30) **or** stored printer name differs. CMYK Stage 1 shows "No calibration applied" reminder.
## UI
Channel-response SVG (`#calCurveSvg`): solid = post-linearization, dashed = identity. TAC card + per-channel limit editors. Recommended power shown for `targen -p`.
+45
View File
@@ -0,0 +1,45 @@
# 08 — Stage 1: `targen`
UI: `#stage-1`. Action: `#btnGenerate``run_targen`.
## `TargenConfig` → argv (`build_targen_args`)
Always starts `-v -d {2|4}`.
| UI | Config field | Flag | Notes |
|----|--------------|------|-------|
| Colour space RGB/CMYK | `colour_space` | `-d 2` / `-d 4` | 2 = Print RGB, 4 = CMYK |
| Patch count | `patch_count` or `total_patches` | `-f N` | omitted if 0. **Must honour UI**#44 shipped 836 because `-f` was dropped |
| White patches | `white_patches` | `-e N` | |
| Black patches | `black_patches` | `-B N` | |
| Grey steps | `grey_steps` | `-g N` | if > 0 |
| Single channel steps | `single_channel_steps` | `-s N` | if > 0 |
| Neutral steps | `neutral_steps` | `-n N` | if > 0 |
| Neutral concentration | `neutral_concentration` | `-N x.xx` | only if not ≈ 0.50 |
| Preconditioning profile | `preconditioning_profile` | `-c path` | file filter **`.icc/.icm/.mpp`** not `.ti*` (#172) |
| OFPS high quality | `ofps_high_quality` | `-G` | |
| OFPS adaptation | `ofps_adaptation` | `-A x.xx` | |
| Full spread algorithm | `full_spread_algorithm` | `-t -r -R -q -Q -i -I` | default OFPS = no extra flag |
| Total ink limit | `total_ink_limit` | `-l N` | **CMYK only**, 1400 |
| Dark emphasis | `dark_emphasis` | `-V x.xx` | if not ≈ 1.0 |
| Device power | `device_power` | `-p x.xx` | if not ≈ 1.0 and > 0 |
| Basename | `basename` | trailing arg | no extension |
Process id: `targen_${basename}`. Cwd: resolved working directory.
Fork `targen -u` JSON progress exists but **ICCery does not pass `-u`**.
## Patch-count presets
`#patchCountPreset`: 400 Draft, **800 Standard (default)**, 1500 Photo, 2500+ custom via `#patchCountCustom`.
## Extra actions
- `#btnOpenExisting` — resume `.ti1`/`.ti2` (#140). **Open** dialog (#103 used save by mistake).
- `#btn-import-dataset` — CGATS/ti3 import (#94). Open dialog, not save (#211).
- `#btnBrowse` — save `.ti1` location (`select_target_file`).
- `#btnToggleAllHelp` — help mode; tooltips must **not** reflow layout (#171) — use overlay positioning, not in-flow height.
## Advanced options
Collapsible `#targenAdvancedDetails`. Ink-limit group `#targenInkLimitGroup` hidden for RGB.
+56
View File
@@ -0,0 +1,56 @@
# 09 — Stage 2: `printtarg` and target layout
UI: `#stage-2`. Action: `#btnCreateLayout``run_printtarg`.
## `PrinttargConfig` → argv (`build_printtarg_args`)
```
-v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
```
| UI | Field | Flag |
|----|-------|------|
| Instrument | `instrument` | `-i i1\|p3\|CM\|SS\|20\|22\|41\|51` |
| Page size | `page_size` | `-p A4\|A4R\|A3\|A2\|Letter\|LetterR\|Legal\|4x6\|11x17\|WWWxHHH` |
| Custom page | `customPageW/H` | `-p {w}x{h}` millimetres, ≥ 50 |
| Bit depth 8/16 | `bit_depth` | `-t` 8-bit TIFF / `-T` 16-bit |
| DPI | `dpi` | after `-t/-T`, default **300** |
| Layout order | `random_seed`, `no_randomize` | see below |
| Custom label | `custom_label` | `-d` string (fork #19 / ICCery #119) |
| Calibration | `calibration_file`, `calibration_embed_only` | `-K file.cal` apply, `-I file.cal` embed only |
Process id: `printtarg_${basename}`. **Always** passes fork `-u` so a JSON manifest can be parsed from stdout.
## Randomisation (#163)
Default **must** be deterministic (`-R 1`). A missing seed produced different TIFF layouts every run, breaking re-prints.
| `#printtargLayoutOrder` | Flags |
|-------------------------|-------|
| `deterministic` (default) | `-R 1` |
| `custom_seed` | `-R N` (N ≥ 1) |
| `raster` | `-r` (no randomize) — **not** `-R` |
Do **not** confuse printtarg `-r` (raster/no-random) with targen's `-r` (full-spread algorithm).
## Custom label (`-d`)
Assembled as:
```
ICCery - {basename} - {printer} - {ink} - {driverPaper} - {actualPaper} - DD/MM/YYYY HH:MM
```
Manual override via `#targetLabelPreview`. Fork `printtarg -d` is the **label**, not colour space (targen `-d`) and not iccgamut density (`iccgamut -d`).
## JSON manifest
Pretty-printed bare JSON on stdout (no prefix). Frontend `extractManifest` scans accumulated stdout. Typical fields: `pages[]` with TIFF paths, patch counts, instrument. Gallery renders each page via `read_tiff_preview_png`**never** feed TIFF bytes to `<img>` (#58). Max edge 1200 px, Lanczos3, PNG base64.
## Colour-management warning
`#cmWarningBanner` tells the user to set the driver to "No Colour Adjustment" (Epson) / "Off (No Colour Adjustment)" (Canon). Native print then **also** forces bypass (see [11](11-print-macos.md) / [12](12-print-windows.md)).
## Raw print panel
After successful printtarg, `#rawPrintPanel` is shown. `#cupsOptionsGroup` (PPD uncorrected passthrough) is **hidden on Windows** (#48).
+181
View File
@@ -0,0 +1,181 @@
# 10 — Print system (shared types & UI)
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## Architecture map
```
Stage 2 UI (src/js/printtarg.js + src/index.html #rawPrintPanel)
│ invoke("get_printers")
│ invoke("get_printer_capabilities", { printerName })
│ invoke("show_printer_properties", { printerName })
│ invoke("print_target_native", { printerName, tiffPath, options })
src-tauri/src/commands.rs (cfg-gated dispatch)
├── windows → print/windows.rs GDI + DEVMODE + SetICMMode(ICM_OFF)
├── macos → print/macos.rs NSPrintPanel + private SPI + lp
└── unix → print/unix.rs lpstat/lpoptions + lp -o raw
```
Shared types live in `src-tauri/src/print/mod.rs`. `PrinterDevModeStore` is a Tauri managed state (`lib.rs:53`). macOS re-exports Unix enumeration (`macos.rs:10`).
ICCery-CPU (`TargetPrint.app`) is a **separate** AppKit process that ICCery is specified to spawn fire-and-forget with `--job <json>`. It is **not yet wired** into the Tauri commands; the current ICCery print path is in-process / `lp`.
---
## Shared print types (`src-tauri/src/print/mod.rs`)
All structs derive `Debug, Clone, Serialize, Deserialize`. Frontend JS uses **snake_case** field names when building the `PrintOptions` payload (see `printtarg.js:268-275`), matching serde's default.
### `Printer` (mod.rs:613)
| Field | Type | Meaning |
|-------|------|---------|
| `name` | `String` | OS queue / destination name. Windows: `PRINTER_INFO_4W.pPrinterName`. Unix/macOS: CUPS destination from `lpstat -e` / `-p`. This is the value sent back as `printerName` to all subsequent commands. |
| `status` | `String` | Windows always `"Ready"`. Unix: `"Idle"` / `"Printing"` / `"Stopped"` / `"Unknown"` from `lpstat -p`. |
| `is_default` | `bool` | Windows: always `false` (default printer is **not** queried). Unix: true when name matches `lpstat -d` (`system default destination:`). |
| `display_name` | `Option<String>` | Human-readable CUPS `printer-info` from `lpoptions -p <name>`. Windows always `None`. Used on macOS as `NSPrinter::printerWithName` fallback when `PMPrinterCreateFromPrinterID` fails (#188). Serde `#[serde(default)]`. |
AGENTS.md warns: adding fields requires updating **every** platform constructor (`windows.rs`, `unix.rs`) or the other OS builds break.
### `PrinterTray` (mod.rs:1619)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). |
### `PrinterPaperSize` (mod.rs:2225)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `u16` | Windows: `DC_PAPERS` DMPAPER_* id. Unix: 1-based index of `PageSize` / `MediaSize` choice. |
| `name` | `String` | Windows: 64-WCHAR `DC_PAPERNAMES`, or `"Paper Size {id}"`. Unix: PPD token (`A4`, `Letter`, …). |
**Gap:** the frontend never populates a paper-size `<select>` from `caps.paper_sizes`. Stage 2's `#pageSizeSelect` is the **printtarg layout** page size (mm), not the printer's PPD `PageSize`. `PrintOptions.paper_size` is set from that same `#pageSizeSelect` (`printtarg.js:271`) and forwarded to `lp -o PageSize=` on Unix/macOS. Windows `apply_print_options_to_devmode` **ignores** `paper_size`.
### `PrinterMediaType` (mod.rs:2831)
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `String` | Windows: `DC_MEDIATYPES` DWORD as decimal string (parsed back to `u32` for `dmMediaType`). Unix: PPD choice token (`92`, `13`, `Plain`, …). When a PPD file is readable, `id` is the machine token and `name` is the human label after `/`. |
| `name` | `String` | Display label. Unix `lpoptions -l` path uses the same token for both. Unix PPD path (`parse_ppd_media_types`) splits `*CNIJMediaType 42/Photo Paper Plus Semi-gloss:` into id=`42`, name=`Photo Paper Plus Semi-gloss`. |
### `PrinterCapabilities` (mod.rs:3440)
| Field | Type | Meaning |
|-------|------|---------|
| `trays` | `Vec<PrinterTray>` | Paper sources. |
| `paper_sizes` | `Vec<PrinterPaperSize>` | Queried but unused by the Stage 2 UI. |
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
### `PrintOptions` (mod.rs:4255) — `Default` + `PartialEq + Eq`
| Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` |
| `orientation` | `Option<String>` | `"landscape"``DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
| `media_type` | `Option<String>` | parsed as `u32``dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
| `ppd_uncorrected_passthrough` | `Option<bool>` | **ignored** | if true: `-o ColorModel=Gray -o cm-calibration` instead of `-o raw` | **ignored as a gate**; macOS always ColorSync-bypasses and always injects driver color-bypass. Native panel sets this to `Some(true)` on OK. |
| `cups_options` | `Option<String>` | **ignored** | **ignored** | space-separated `key=value` captured from `PMPrintSettingsToOptions`, filtered, then expanded to `-o key=value`. `#[serde(default)]`. |
### `PrintPropertiesResult` (mod.rs:5761)
Returned by `show_printer_properties` on macOS only.
| Field | Type | Meaning |
|-------|------|---------|
| `selected_printer` | `Option<String>` | CUPS Printer ID from `PMPrinterGetID` after the panel, or the original `printer_name` if the session printer cannot be read but `PMPrinterCreateFromPrinterID` had succeeded. `None` if the panel was opened via the `NSPrinter::printerWithName(display_name)` fallback. |
| `options` | `PrintOptions` | Snapshot: `media_type` extracted from captured options, `cups_options` filtered string, `ppd_uncorrected_passthrough: Some(true)`, everything else `Default`. |
Windows `show_printer_properties` returns `Ok(None)` from the Tauri command after storing DEVMODE (the inner fn returns `Result<(), String>`). Linux returns `Ok(None)` with no dialog. Frontend treats `null` as **user cancelled** (`printtarg.js:390-393`) — so on Windows a successful Preferences OK **also** shows "Printer properties dialog cancelled." That is a known UX mismatch: Windows success and cancel both surface as `null`.
### `PrinterDevModeStore` (mod.rs:6387)
```rust
pub struct PrinterDevModeStore {
pub devmodes: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
```
- Keyed by printer name.
- Stores the **full** `DocumentPropertiesW` output buffer (public `DEVMODEW` + `dmDriverExtra` private OEM bytes). This is the #36 fix.
- `get` clones; `set` inserts. Poisoned mutex → `get` returns `None`, `set` is a no-op.
- `#[allow(dead_code)]` because the type is compiled on all platforms; only Windows uses it.
- Constructed once in `lib.rs:53`: `.manage(print::PrinterDevModeStore::new())`.
- Session-only. Not persisted to disk.
### Module cfg gates (mod.rs:8999)
```
#[cfg(windows)] pub mod windows;
#[cfg(any(target_os = "macos", test))] pub mod macos;
#[cfg(any(unix, test))] pub mod unix;
#[cfg(test)] mod tests;
```
macOS **is** unix, so both `macos` and `unix` compile on macOS. Tests always compile both `macos` and `unix`. `macos.rs` `pub use`s `unix::{get_printer_capabilities, get_printers}`.
---
## Tauri command surface (`src-tauri/src/commands.rs:13041415`)
Registered in `lib.rs:9295`.
| Command | Args | Windows | macOS | Linux |
|---------|------|---------|-------|-------|
| `get_printers` | none | `windows::get_printers` | `macos::get_printers` (= unix) | `unix::get_printers` |
| `get_printer_capabilities` | `printer_name: String` | DeviceCapabilitiesW | unix lpoptions/PPD | unix lpoptions/PPD |
| `show_printer_properties` | `printer_name: String` + `AppHandle` + `State<PrinterDevModeStore>` | `DocumentPropertiesW` modal; returns `Ok(None)` | `NSPrintPanel` on main thread; returns `Option<PrintPropertiesResult>` | **no-op** `Ok(None)` |
| `print_target_native` | `printer_name`, `tiff_path`, `options: Option<PrintOptions>` | GDI path, passes store | `macos::print_target``lp` | `unix::print_target``lp` |
IPC argument casing is camelCase at the Tauri boundary (`printerName`, `tiffPath`) and snake_case inside the nested `PrintOptions` struct because the frontend builds that object itself.
---
## Frontend (`src/js/printtarg.js` + `src/index.html`)
### Stage 2 print UI (`index.html:568638`)
Hidden `#rawPrintPanel` revealed after `printtarg` succeeds and a JSON manifest is parsed.
- `#printerSelect` + `#btnRefreshPrinters` (↻) + `#btnPrinterProperties` (⚙️ Preferences, class `.btn-properties`) + `#printerStatusBadge`
- `#cupsOptionsGroup` / `#chkPpdFallback` — "PPD Uncorrected Passthrough (Fallback)". **Hidden on Windows** (#48, `printtarg.js:167-170`) via `navigator.userAgent` / `userAgentData.platform`.
- `#printerTraySelect`, `#printerMediaTypeSelect`
- Orientation toggle `#btnOrientPortrait` / `#btnOrientLandscape` (default portrait)
- `#btnPrintAll` — "Print All Pages (Bypass CM)"
- `#btnAdvanceToStage3`
- Per-page "Print Page" buttons on gallery cards (`printtarg.js:737-748`)
There is **no** printer paper-size dropdown in the raw-print panel. `#pageSizeSelect` above is printtarg's layout size and is reused as `PrintOptions.paper_size`.
### `capturedCupsOptions` (`printtarg.js:1417, 266, 411-415`)
Module-level `{}` keyed by **printer name**. Populated only when macOS `show_printer_properties` returns a result with `options.cups_options`. Cleared for that printer if the result has no cups_options. Fed into every subsequent `print_target_native` via `getSelectedPrintOptions()`.
Cancellation: `result === null` → info toast, **no** map mutation.
If the user switched printer inside `NSPrintPanel`, the dropdown is updated when that CUPS id exists in the option list (`printtarg.js:402-409`). Captured media type is applied to `#printerMediaTypeSelect` when a matching option exists.
### Print payload (`printtarg.js:260-276`)
```js
{
paper_source: trayVal ? parseInt(trayVal, 10) : null, // NaN if unix tray name is non-numeric — Unix tray ids are 1-based indexes so this is OK
orientation: selectedOrientation, // "portrait" | "landscape"
paper_size: pageSizeSelect.value, // printtarg page size, not printer PageSize
media_type: mediaType || null,
ppd_uncorrected_passthrough: chkPpdFallback.checked,
cups_options: capturedCupsOptions[activePrinter] || null,
}
```
### Wizard state
`wizardState.printerName` is set at spool time (`printtarg.js:579, 625`) so Stage 5 verification history (#95) can record the device.
---
+369
View File
@@ -0,0 +1,369 @@
# 11 — macOS printing and ColorSync suppression
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
This is the most implementation-sensitive chapter. A rewrite that opens System Settings or the CUPS web UI instead of `NSPrintPanel` will regress #188.
## macOS (`src-tauri/src/print/macos.rs`) — MOST DETAIL
864 lines. This is the ColorSync-suppression engine built for #92 and rewritten for #188.
### What Preferences opens — NSPrintPanel vs CUPS web UI vs System Settings
**Issue #188 (user report):** clicking Preferences opened **System Settings → Printers & Scanners** and did not bind the selected Epson/Canon queue. No driver PDE, so no "Off (No Color Adjustment)".
**Current code (AGENTS.md:105-118, macos.rs:245-516, 679-710):** Preferences opens a **native AppKit `NSPrintPanel`**, pre-bound to the selected CUPS destination via Core Printing. It is **not**:
- CUPS web UI (`http://localhost:631/printers/…`)
- System Settings → Printers & Scanners
- `NSWorkspace` open of the printer
- `lpoptions` GUI
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation.
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
1. Resolve `display_name` **off** the main thread via `unix::get_printer_display_name` (`lpoptions -p`).
2. `app.run_on_main_thread``run_native_print_panel`.
3. `tokio::sync::oneshot` awaits the result.
### objc2 crates (`Cargo.toml:42-47`, AGENTS.md:120-126)
```
objc2 0.6 MainThreadMarker
objc2-app-kit 0.3.2 features = ["NSPrintInfo", "NSPrintPanel", "NSPrinter"]
objc2-foundation 0.3.2 NSString
objc2-core-foundation 0.3.2 CFString, CFType
objc2-application-services 0.3.2 features = ["PMCore", "PMDefinitions", "PrintCore"]
PMPrintSession, PMPrinter, PMPrintSettings, PMPageFormat, …
```
Local `extern "C" { fn free; fn dlsym; }`. No `libloading` crate.
### Binding the selected printer (macos.rs:276-347)
CUPS destination IDs ≠ NSPrinter display names. Core Printing "Printer ID" **is** the CUPS queue name.
```
printer_id_cf = CFString(printer_name)
pm_printer = PMPrinterCreateFromPrinterID(&printer_id_cf)
if pm_printer is null:
fallback NSPrinter::printerWithName(display_name)
print_info.setPrinter(ns_printer)
print_info.setUpPrintOperationDefaultValues()
if that also fails → Err("No printer found for '…'")
pm_session = print_info.PMPrintSession()
pm_settings = print_info.PMPrintSettings()
pm_page_format = print_info.PMPageFormat()
if pm_printer was created from ID:
PMSessionSetCurrentPMPrinter(pm_session, pm_printer) // non-zero → Err
PMSessionDefaultPrintSettings(pm_session, pm_settings) // warn on fail
PMSessionDefaultPageFormat(pm_session, pm_page_format) // warn on fail
```
`Printer.display_name` is the CUPS `printer-info` cached at enumeration. That is what System Settings shows ("Epson XP-55 Series") while `printer_name` is the queue id (`EPSON_XP_55_Series`). #188 failed because the old path used the wrong name with the wrong API.
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
### ColorSync suppression strategy — UI click to `lp`
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS.
```
[Preferences click]
show_printer_properties
run_on_main_thread
run_native_print_panel
① PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
NSPrintPanel.runModalWithPrintInfo
user picks media / quality (color locked)
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult
[frontend]
capturedCupsOptions[printer] = cups_options
[Print Target]
print_target_native → macos::print_target → build_lp_args
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching
THEN captured cups_options as -o k=v
THEN media_type if not already present (detected key)
THEN detect_driver_color_bypass if no color-bypass key yet
THEN orientation / PageSize if not already present
lp -d <queue> -t "ICCery Target - …" … <tiff>
```
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
### Layer ② — private SPI via `dlsym` (`set_session_color_matching_mode`, macos.rs:32-130)
Used by Photoshop, Lightroom, X-Rite i1Profiler to gray-out Color Matching in driver PDEs. Undocumented; resolved at runtime so the binary does not hard-link a private symbol.
```
RTLD_DEFAULT = (-2isize) as *mut c_void
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeLock")
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingMode")
dlsym(RTLD_DEFAULT, "PMSessionSetColorMatchingModeNoLock")
```
**Signature (all three):**
```c
OSStatus PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef mode);
OSStatus PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef mode);
OSStatus PMSessionSetColorMatchingModeNoLock(PMPrintSession, CFStringRef mode);
```
Rust:
```rust
type ModeFn = unsafe extern "C" fn(PMPrintSession, *const CFString) -> i32;
```
`status == 0` is success (`noErr`).
**Priority:**
1. `PMSessionSetColorMatchingModeLock` — sets **and locks** the UI in one call (controls grayed).
2. `PMSessionSetColorMatchingMode` — sets mode; lock behaviour unspecified.
3. `PMSessionSetColorMatchingModeNoLock` — sets without locking.
For each symbol, try mode strings in order:
```
"AP_ApplicationColorMatching" // kPMApplicationColorMatching
"ApplicationColorMatching" // unprefixed alias
```
First `(symbol, mode)` that returns 0 wins; then break both loops.
**Preconditions:** `pm_session` non-null; `PMSessionGetCurrentPrinter` succeeds with a non-null printer. Otherwise skip SPI (session has no printer to attach a PDE to).
**Stub on non-macOS:** always `false` so unit tests compile.
If all fail: log `"All PMSessionSetColorMatchingMode calls failed; color controls may not be grayed"` and fall through to public `PMPrintSettingsSetValue`.
### Why `AP_ColorSyncMatching` and `AP_VendorColorMatching` are avoided
AGENTS.md:118, macos.rs:82-88:
> Only application-managed color-matching modes are appropriate for profiling targets. ColorSync or vendor modes would apply color management and corrupt the target patches.
| Mode string | Effect on a profiling target |
|-------------|------------------------------|
| `AP_ApplicationColorMatching` / `ApplicationColorMatching` | **Used.** "Application already did color." Filters/PDEs must not transform pixels. UI Color Matching grayed. |
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries.
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
```
key = "AP_ColorMatchingMode" and "AP.ColorMatchingMode"
val = "AP_ApplicationColorMatching"
lock = true
PMPrintSettingsSetValue(pm_settings, key, val, true)
```
The dotted `AP.ColorMatchingMode` is a **legacy form used by some raster drivers and PDEs** (macos.rs:540-541). Both are always written.
### Layer ④ — pre-select driver "no color adjustment" in the panel (macos.rs:377-397, 422-432)
`lpoptions -p <printer> -l``unix::detect_driver_color_bypass`:
| Driver | Key | Value |
|--------|-----|-------|
| Canon | `CNIJIntent2` | `4` |
| Canon (old) | `CNIJIntent` | `4` |
| Epson | `EPIJ_CCor` | `0` (if that key exists, preferred) |
| Epson | `EPIJ_CMat` | `3` |
| Gutenprint | `StpColorCorrection` | `Uncorrected` |
| generic | `ColorCorrection` | `Uncorrected` |
| Epson alt | `EpsonColorMode` | `Off` |
Written twice: `PMPrintSettingsSetValue(..., lock=false)` **and** `print_info.printSettings().insert`. Lock is false so the user can still change media/quality; only color matching is intended to be locked by the SPI.
### Layer ⑤ — `NSPrintInfo.printSettings` dictionary (macos.rs:399-432)
AppKit PDEs and some raster drivers read this `NSMutableDictionary`, not the PM object.
```
print_info.updateFromPMPageFormat()
print_info.updateFromPMPrintSettings()
print_settings.insert("AP_ColorMatchingMode", "AP_ApplicationColorMatching")
print_settings.insert("AP.ColorMatchingMode", "AP_ApplicationColorMatching")
print_settings.insert(<bypass_key>, <bypass_val>)
```
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
### Panel options (macos.rs:434-449)
```
NSPrintPanel::printPanel(mtm)
opts = NSPrintPanelOptions::all()
opts.insert(ShowsPageSetupAccessory)
panel.setOptions(opts)
panel.setDefaultButtonTitle(Some("Use Settings"))
response = panel.runModalWithPrintInfo(&print_info)
```
`response != 1` (`NSModalResponseOK` / `NSOKButton`) → **`Ok(None)`** (cancellation is not an error). `PMPrinter` released.
### Layer ⑥ — `PMPrintSettingsToOptions` capture (macos.rs:452-515)
After OK:
1. `PMSessionGetCurrentPrinter``PMPrinterGetID``selected_printer` string.
2. `panel.printInfo().PMPrintSettings()`.
3. `PMPrintSettingsToOptions(updated_settings, &mut opts_ptr)` — CUPS malloc'd C string of `key=value key=value …`.
4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string``cups_options`.
6. `extract_media_type_from_options``media_type`.
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
Forwarded from the panel to `lp`:
```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot
Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
Quality: Resolution, cupsPrintQuality, Quality, EPIJ_Quality, CNIJQuality,
StpQuality, OutputMode
Duplex: Duplex, sides
```
`is_relevant_cups_option` (macos.rs:178-204) additionally:
- Drops empty keys
- Drops `com.apple.*` ticket keys
- Drops `AP_ColorMatchingMode` and `AP.ColorMatchingMode` (we always set those ourselves)
- Drops empty values (`AP_D_InputSlot=`)
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
### `build_lp_args` (macos.rs:518-643)
Always, even with `options=None`:
```
lp -d <printer> -t "ICCery Target - <filename>"
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
… captured / detected options …
<tiff_path>
```
Order after the two AP_* flags:
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`.
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it.
3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.**
4. Orientation → `orientation-requested=4|3` unless already present.
5. `PageSize=` unless `pagesize` already present.
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS.
### `print_target` (macos.rs:646-677)
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
### Cancellation as `None`
`run_native_print_panel``Ok(None)` on cancel. `show_printer_properties` propagates that. Frontend: `if (result === null) { showNotification("info", "Printer properties dialog cancelled."); return; }`.
### Preferences crash / #188
#188 was not a segfault; it was a **wrong UI**: System Settings instead of the driver PDE, so color management could not be disabled on Epson XP-55 / Canon Pro 9500 II.
The rewrite:
- Bind by CUPS Printer ID (`PMPrinterCreateFromPrinterID` + `PMSessionSetCurrentPMPrinter`), not by opening System Settings.
- `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected and re-applied on `lp`.
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`.
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
### macOS tests (macos.rs:712-863 + tests.rs:278-318)
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path.
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
- Missing TIFF errors.
---
## ColorSync suppression — complete key/SPI/flag roster
### Private SPI symbols (dlsym, macOS only)
```
PMSessionSetColorMatchingModeLock (PMPrintSession, CFStringRef) -> i32
PMSessionSetColorMatchingMode (PMPrintSession, CFStringRef) -> i32
PMSessionSetColorMatchingModeNoLock (PMPrintSession, CFStringRef) -> i32
```
Mode strings attempted: `AP_ApplicationColorMatching`, `ApplicationColorMatching`.
Public Core Printing used around the SPI:
```
PMPrinterCreateFromPrinterID
PMSessionSetCurrentPMPrinter
PMSessionGetCurrentPrinter
PMSessionDefaultPrintSettings
PMSessionDefaultPageFormat
PMPrintSettingsSetValue
PMPrintSettingsToOptions
PMPrinterGetID
PMRelease
```
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
### CUPS / lp flags
| Flag | Platform | When |
|------|----------|------|
| `-o AP_ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
| `-o AP.ColorMatchingMode=AP_ApplicationColorMatching` | macOS | always |
| `-o CNIJIntent2=4` | macOS (auto) / if captured | Canon |
| `-o CNIJIntent=4` | macOS auto if no Intent2 | Canon old |
| `-o EPIJ_CCor=0` | macOS auto if present in lpoptions | Epson |
| `-o EPIJ_CMat=3` | macOS auto | Epson Off |
| `-o StpColorCorrection=Uncorrected` | macOS auto | Gutenprint |
| `-o ColorCorrection=Uncorrected` | macOS auto | generic |
| `-o EpsonColorMode=Off` | macOS auto | Epson alt |
| `-o CNIJMediaType=` / `EPIJ_Medi=` / `StpMediaType=` / `MediaType=` | macOS (detected key); Linux always `MediaType=` | media |
| `-o PageSize=` | macOS/Linux | if set |
| `-o orientation-requested=3\|4` | macOS/Linux | portrait/landscape |
| `-o raw` | **Linux only** | default |
| `-o ColorModel=Gray -o cm-calibration` | **Linux only** | PPD fallback checkbox |
| captured `InputSlot`, `AP_D_InputSlot`, `Resolution`, `cupsPrintQuality`, `Quality`, `EPIJ_Quality`, `CNIJQuality`, `StpQuality`, `OutputMode`, `Duplex`, `sides`, `EPIJ_OSColMat`, `ColorModel` | macOS | if panel produced them |
### PPD keys (detection)
```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
```
---
+170
View File
@@ -0,0 +1,170 @@
# 12 — Windows GDI raw printing
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## Windows (`src-tauri/src/print/windows.rs`)
Direct Win32. No `lp`, no XPS, no `winprint` crate. Evaluated and rejected in #26.
### Exact Win32 APIs used
From `windows` crate 0.61 (`Cargo.toml:50-55`: `Win32_Foundation`, `Win32_Graphics_Gdi`, `Win32_Graphics_Printing`, `Win32_UI_WindowsAndMessaging`) plus a local `extern "system"` block for symbols the crate feature set does not expose.
| API | Header / crate | Where | Purpose |
|-----|----------------|-------|---------|
| `EnumPrintersW` | `Win32::Graphics::Printing` | `get_printers` | Level 4 (`PRINTER_INFO_4W`) first; fall back to Level 1 (`PRINTER_INFO_1W`) |
| `OpenPrinterW` | Printing | properties + print | `PRINTER_HANDLE` |
| `ClosePrinter` | Printing | both | |
| `DocumentPropertiesW` | Printing | properties + print | size query (`fMode=0`), `DM_OUT_BUFFER` init, `DM_IN_PROMPT\|DM_IN_BUFFER\|DM_OUT_BUFFER` dialog |
| `GetForegroundWindow` | `Win32::UI::WindowsAndMessaging` | properties | parent HWND of the modal |
| `DeviceCapabilitiesW` | **local extern** | capabilities | `DC_BINS`(6), `DC_BINNAMES`(12), `DC_PAPERS`(2), `DC_PAPERNAMES`(16), `DC_MEDIATYPES`(35), `DC_MEDIATYPENAMES`(34) |
| `CreateDCW` | `Win32::Graphics::Gdi` | print | printer DC with DEVMODE |
| `DeleteDC` | Gdi | print | |
| `GetDeviceCaps` | Gdi | print | `LOGPIXELSX/Y`, `HORZRES`, `VERTRES` |
| `SetICMMode` | **local extern** | print | `ICM_OFF = 1` |
| `StartDocW` / `StartPage` / `EndPage` / `EndDoc` | **local extern** | print | GDI job |
| `StretchDIBits` | Gdi | print | unmanaged 24-bit BGR DIB |
`DOCINFOW` is a **local** `#[repr(C)]` struct (windows.rs:24-31), not the crate type, because `StartDocW` is also local-extern.
Constants (windows.rs:48-66):
```
ICM_OFF = 1
DM_IN_PROMPT = 4
DM_IN_BUFFER = 8
DM_OUT_BUFFER = 2
DM_ORIENTATION = 0x00000001
DM_DEFAULTSOURCE = 0x00000200
DM_ICMMETHOD = 0x00800000
DMICMMETHOD_NONE = 1
DMORIENT_PORTRAIT = 1
DMORIENT_LANDSCAPE = 2
DM_MEDIATYPE = 0x02000000
DC_PAPERS=2, DC_BINS=6, DC_BINNAMES=12, DC_PAPERNAMES=16,
DC_MEDIATYPENAMES=34, DC_MEDIATYPES=35
IDOK = 1
```
### Enumeration (`get_printers`, windows.rs:78-168)
1. `EnumPrintersW(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, NULL, 4, …)` to get byte count.
2. If `bytes_needed == 0`, retry at Level 1 (older/limited spooler).
3. Level 4: `pPrinterName`. Level 1: `pName`.
4. `is_default` always false, `display_name` always `None`, `status` always `"Ready"`.
5. Empty list is success, not error.
### Capabilities (`get_printer_capabilities`, windows.rs:171-326)
`DeviceCapabilitiesW(printer, NULL port, cap, …)`. Bin names are 24 WCHARs each; paper and media names 64 WCHARs. Media type IDs are read as `u32` via a `*mut u16` cast of a `Vec<u32>` (windows.rs:288-289) — the Win32 API writes DWORDs for `DC_MEDIATYPES`.
`supports_orientation: true` unconditionally.
### Driver preferences dialog (`show_printer_properties`, windows.rs:329-401) — #34 / #36
```
OpenPrinterW
DocumentPropertiesW(..., fMode=0) → required buffer size
load cached DEVMODE or zeros
if all-zero: DocumentPropertiesW(DM_OUT_BUFFER) to populate defaults
DocumentPropertiesW(DM_IN_PROMPT | DM_IN_BUFFER | DM_OUT_BUFFER)
ClosePrinter
if res == IDOK (1): store out_buf, Ok(())
if res == 2: user cancel, Ok(()) // IDCANCEL
else: Err(...)
```
Parent window is `GetForegroundWindow()`, **not** the Tauri webview HWND. The dialog is modal to whatever is foreground.
The **entire** `out_buf` (header + `dmDriverExtra`) is stored. That is the #36 fix: Epson "Print Preview" and other OEM private flags live past `dmSize` and were previously dropped.
### ICM bypass + option overlay (`apply_print_options_to_devmode`, windows.rs:404-437)
Always:
```
dmFields |= DM_ICMMETHOD
dmICMMethod = DMICMMETHOD_NONE // 1
```
Then, if options present:
- `paper_source``dmFields |= DM_DEFAULTSOURCE`; `dmDefaultSource = tray_id as i16`
- `orientation == "landscape"` (case-insensitive) → `DMORIENT_LANDSCAPE` else portrait
- `media_type` parsed as `u32``dmFields |= DM_MEDIATYPE`; `dmMediaType = id`
**Not applied:** `paper_size`, `ppd_uncorrected_passthrough`, `cups_options`. Private OEM bytes after the public header are **not** touched (verified by `tests.rs:219-276`).
### GDI raw print path (`print_target`, windows.rs:440-643)
1. `image::open` TIFF → `to_rgb8()`. Crate features: `png`, `tiff` (`Cargo.toml:40`).
2. Pack **24-bit BGR** DIB, 4-byte row stride: `row_stride = ((w*3+3)/4)*4`. Top-down (`biHeight` negative).
3. `OpenPrinterW``DocumentPropertiesW` size → reuse cached DEVMODE (resized up if needed) or `DM_OUT_BUFFER` defaults → `apply_print_options_to_devmode`.
4. `CreateDCW(NULL, printer, NULL, pDevMode)`.
5. **`SetICMMode(hdc, ICM_OFF)`** — "STRICT ICM BYPASS".
6. `StartDocW` with title `ICCery Target - <filename>`. `lpszDatatype` null (driver default, not `"RAW"`).
7. `StartPage`.
8. Auto-fit scaler (#34):
```
dpi_x/y = GetDeviceCaps(LOGPIXELSX/Y) // used only for BITMAPINFO biX/YPelsPerMeter
page_w/h = GetDeviceCaps(HORZRES/VERTRES)
scale = min(page_w/img_w, page_h/img_h)
dest = floor(img * scale), centered
```
Physical offsets (`PHYSICALOFFSETX/Y`) from #34's wish-list are **not** queried. Fit is to the printable DC area (`HORZRES`/`VERTRES`), which already excludes hardware margins.
9. `StretchDIBits(..., DIB_RGB_COLORS, SRCCOPY)`. This **can resample** if dest ≠ source pixels. Color is unmanaged (ICM off, BI_RGB) but geometric interpolation is GDI's. Contrast with ICCery-CPU, which forbids interpolation.
10. `EndPage` / `EndDoc` / `DeleteDC` / `ClosePrinter`.
Zero-dimension images error out before GDI. Missing TIFF: `"Target TIFF file not found: …"`.
### CREATE_NO_WINDOW vs print
`CREATE_NO_WINDOW` (`0x08000000`) is **not used on the print path**. Windows printing is in-process GDI; no child process is spawned.
`CREATE_NO_WINDOW` **is** applied to ArgyllCMS console-subsystem children so a black `cmd` window does not flash (#46):
- `src-tauri/src/process_manager.rs:99-103` — every `ProcessManager::spawn` (`targen`, `printtarg`, `chartread`, `colprof`, `profcheck`, `instlist`, …)
- `src-tauri/src/calibration.rs:849-853``printcal` / `applycal` captured runs
`printtarg` itself only **generates** TIFFs; it does not print. Native print is a separate IPC command.
### NSIS USB drivers (#127) — not printer drivers
USB install is for **spectrophotometer** libusb-win32 drivers (i1Pro, ColorMunki, SpyderPrint), **not** printer OEM drivers.
- Fetch: `scripts/fetch-argyll.mjs` copies `Argyll_V*/usb/``src-tauri/argyll/usb/` from the Windows zip (`ArgyllCMS_install_USB.exe`, `.inf`, `.cat`, `libusb0.sys` for x86/amd64/arm64).
- Bundle: `tauri.conf.json` `resources: ["argyll/**/*"]` + NSIS `installMode: "both"` + `installerHooks: "windows/hooks.nsh"`.
- `hooks.nsh` `NSIS_HOOK_PREINSTALL`: if elevated, `MessageBox` Yes/No "Install ArgyllCMS USB instrument drivers?".
- `NSIS_HOOK_POSTINSTALL`: `ExecWait` `$INSTDIR\argyll\usb\ArgyllCMS_install_USB.exe` or `$INSTDIR\resources\argyll\usb\...`. Not silent. Missing file → exclamation box.
- Uninstall does **not** run `ArgyllCMS_uninstall_USB.exe` (would break other Argyll apps).
- Also maps missing HKCU shell-folder drive letters via `DefineDosDevice` to avoid "Invalid Drive" on domain profiles.
WiX/MSI has no equivalent prompt.
### Windows idiosyncrasies / bugs fixed
| Issue | Symptom | Fix |
|-------|---------|-----|
| #25/#26 | Need ACPU-equivalent raw print | GDI + `SetICMMode(ICM_OFF)` + `DMICMMETHOD_NONE` + 24-bit BGR DIB |
| #34 | No driver dialog / trays / orientation / scaler | `DocumentPropertiesW` modal, `DeviceCapabilitiesW`, auto-fit `StretchDIBits` dest |
| #36 | Preferences (Epson Print Preview, private OEM) ignored | Cache **full** DEVMODE buffer including `dmDriverExtra`; pass that pointer to `CreateDCW` |
| #48 | "PPD Uncorrected Passthrough" shown on Windows | Hide `#cupsOptionsGroup` |
| #46 | Argyll console windows cover UI | `CREATE_NO_WINDOW` on subprocess spawn **only** — not on print |
| #67 | Duplicate `get_windows_printers` / `print_target_windows` | Collapsed to `get_printers` / `print_target_native` |
Remaining gaps:
- Default printer never flagged.
- `paper_size` not applied to `DEVMODE` (`DM_PAPERSIZE` unused).
- `PHYSICALOFFSET*` not used; scaler uses `HORZRES`/`VERTRES` only.
- `StretchDIBits` may interpolate when scaling.
- `show_printer_properties` Tauri result is always `None`, so the frontend reports cancel even on OK.
- No `PrinterProperties` / `AdvancedDocumentProperties` alternative; only `DocumentPropertiesW`.
- `GetForegroundWindow` can attach the modal to the wrong top-level window.
---
+89
View File
@@ -0,0 +1,89 @@
# 13 — Linux CUPS printing
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## Linux (`src-tauri/src/print/unix.rs`)
No libcups FFI. Shell out to CUPS CLI: `lpstat`, `lpoptions`, `lp`. **`lpr` is never used.**
### Enumeration
| Command | Parser | Result |
|---------|--------|--------|
| `lpstat -e` | `parse_lpstat_e` | destination names, one per non-empty trimmed line |
| `lpstat -p` | `parse_lpstat_p` | `(name, status)` from lines `printer <name> …`. Status: idle → Idle; printing/now printing → Printing; disabled/stopped → Stopped; else Unknown |
| `lpstat -d` | `parse_lpstat_d` | `system default destination: <name>` |
| `lpoptions -p <name>` | `extract_printer_display_name` | `printer-info=` value, quotes stripped |
`merge_printer_info`: destinations first (status from `-p` or `"Idle"`), then any `-p` names not already seen. Each entry calls `lpoptions` for `display_name` (N printers = N extra processes).
If both `-e` fails and `-p` cannot even be spawned, error `"Failed to execute 'lpstat': …"`. Otherwise partial data is OK.
### Capabilities / PPD
`lpoptions -p <printer> -l` parsed by `parse_lpoptions_l` (unix.rs:181-229):
| PPD key (before `/`) | Destination |
|----------------------|-------------|
| `InputSlot`, `MediaSource` | trays (1-based index id, `*` stripped) |
| `PageSize`, `MediaSize` | paper_sizes |
| `CNIJMediaType`, `MediaType`, `StpMediaType`, `EPIJ_Medi` | media_types (id = name = token) |
Then, if `/etc/cups/ppd/<printer>.ppd` is readable, `parse_ppd_media_types` **replaces** media_types with id/name pairs from `*CNIJMediaType `, `*MediaType `, `*StpMediaType `, `*EPIJ_Medi ` lines (`id/Human Name:`).
macOS typically has PPDs under `~/Library/Printers` or a CUPS temp copy, **not** `/etc/cups/ppd/`, so the PPD-file enrichment is Linux-centric. macOS still gets media types from `lpoptions -l`.
### Driver color bypass detection (`detect_driver_color_bypass`, unix.rs:251-272)
First-match order:
| Probe substring | Key | Value | Meaning |
|-----------------|-----|-------|---------|
| `CNIJIntent2` | `CNIJIntent2` | `4` | Canon "Off (No Color Adjustment)" |
| `CNIJIntent` | `CNIJIntent` | `4` | older Canon |
| `EPIJ_CCor` | `EPIJ_CCor` | `0` | Epson Color Settings — 0 disables correction (preferred over CMat if both exist) |
| `EPIJ_CMat` | `EPIJ_CMat` | `3` | Epson "Off (No Color Adjustment)" |
| `StpColorCorrection` | `StpColorCorrection` | `Uncorrected` | Gutenprint |
| `ColorCorrection` | `ColorCorrection` | `Uncorrected` | generic |
| `EpsonColorMode` | `EpsonColorMode` | `Off` | Epson |
`EPIJ_OSColMat` is **not** auto-detected (but is forwarded if captured on macOS). `ColorModel` is forwarded if captured, not auto-set on Linux except the Gray fallback below.
### Media type key (`detect_media_type_key`, unix.rs:274-284)
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
### Linux `build_lp_args` / `print_target` (unix.rs:362-457)
```
lp -d <printer> -t "ICCery Target - <filename>"
[-o raw] # default
OR [-o ColorModel=Gray -o cm-calibration] # ppd_uncorrected_passthrough == true
[-o orientation-requested=3|4]
[-o PageSize=<paper_size>]
[-o MediaType=<media_type>] # generic key only — does not call detect_media_type_key
<tiff_path>
```
- **`raw` queue:** CUPS skips filters (no `cgpdftoraster`, no PPD color). The TIFF is sent as the job payload. Printers that cannot consume raw TIFF need the checkbox fallback.
- **Fallback `ColorModel=Gray` + `cm-calibration`:** documented as "uncorrected CUPS passthrough" for devices that reject raw TIFF. `Gray` is a surprising choice for color targets — it is the historical CUPS "don't color-manage" trick, not a conversion of the image to grayscale at the app layer. Still a rewrite risk: a color TIFF with `ColorModel=Gray` may be wrong on some drivers.
- **No ColorSync flags** (those are macOS-only).
- **No auto driver-bypass injection** on Linux (Canon/Epson PPD keys are **not** added unless the user typed them — and there is no Linux Preferences dialog to capture them).
- `paper_source` / `cups_options` ignored.
- `lp` failure: stderr, else stdout, else exit code, wrapped as `"CUPS print job failed: …"`.
- Missing file: `"Target TIFF file not found: …"`.
### Linux Preferences
`commands.rs:1364-1368``show_printer_properties` is a no-op `Ok(None)`. There is no CUPS web-UI (`http://localhost:631`) launcher, no `system-config-printer`, no `gtk-print-unix-dialog`. The `#btnPrinterProperties` click therefore always looks like a cancel on Linux.
### `lp` vs `lpr`
Always `lp` (`Command::new("lp")`). `lpr` is never invoked. `lp` is the CUPS System V client and is what `-o name=value` and `-d dest` are specified against. `lpr` BSD syntax (`-P`, `-o` still works on CUPS) was not used.
### CUPS options string parser (`parse_cups_options_string`, unix.rs:286-332)
Shared with macOS. Whitespace-separated `name=value`; double quotes toggle; quotes stripped from both sides; tokens without `=` skipped; `AP_D_InputSlot=` yields `("",)` empty value.
---
+256
View File
@@ -0,0 +1,256 @@
# 14 — ICCery-CPU / TargetPrint (macOS companion)
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
## ICCery-CPU / TargetPrint
Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `TargetPrint`. Zero third-party deps. Universal 2. Hardened Runtime on, App Sandbox **off** (needs `cupsGetPPD`).
### Why it exists
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but:
- No 1:1 physical-size preview
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
- No AirPrint warning
- NSPrintPanel is settings-only; the job is a second step
TargetPrint is the **Adobe Color Print Utility analogue**: decode TIFF with ImageIO, draw 1:1 in PostScript points with interpolation off, present `NSPrintPanel` as the actual print operation, inject ColorSync-off **and** vendor PPD keys into `NSPrintInfo`.
### Architecture (SPEC §2)
```
ICCery (Tauri) --job /tmp/iccery_job_<uuid>.json (fire-and-forget, do not wait)
TargetPrint.app (always NSApplication, never a CLI tool)
main.swift → AppDelegate → PreviewWindowController
TargetCanvasView (ImageIO, knowsPageRange/rectForPage)
PrintEngine (NSPrintOperation + NSPrintInfo + ColorMatching)
CUPSManager (libcups, AirPrint, vendor PPD)
```
### Invocation contract (SPEC §4, `main.swift`, `AppDelegate.consumeArguments`)
```
TargetPrint.app/Contents/MacOS/TargetPrint --job /tmp/iccery_job_<uuid>.json
TargetPrint --job=/path.json
TargetPrint --verbose --job … # ~/Library/Logs/TargetPrint/TargetPrint.log
# or open a .targetjob / .json via file association (UTI com.iccery.targetjob)
```
Exit codes (`AppError.swift`): `0` ok/cancel, `1` invalid job, `2` unreadable TIFF, `3` CUPS, `4` fatal. Job JSON is **never deleted** by TargetPrint.
Mode B: no args / drop TIFFs / File→Open — inspector + Print….
`--job` presents `NSPrintPanel` immediately (`AppDelegate:32-33`, SPEC §4.5). Tauri must **not** `wait()`.
### JSON `TargetJob` v1 (SPEC §5, `TargetJob.swift`, `Resources/sample.targetjob`)
```json
{
"version": 1,
"jobTitle": "Epson_XP55_IlfordLustre_Target_P1-2",
"files": ["/abs/page1.tif", "/abs/page2.tif"],
"printSettings": {
"printerName": "EPSON_XP_55_Series",
"mediaSize": "A4",
"mediaType": "PremiumGlossy",
"paperSource": "Auto",
"resolution": "5760x1440dpi",
"printQuality": null,
"scaling": 1.0,
"centered": true,
"forceUnmanagedColor": true
},
"uiPolicy": {
"lockColorManagement": true,
"allowBasicDriverChanges": true
}
}
```
Required: `version==1`, `jobTitle`, non-empty `files`, `printSettings.{printerName,mediaSize,scaling,centered,forceUnmanagedColor}`, `uiPolicy.{lockColorManagement,allowBasicDriverChanges}`. Unknown keys ignored. `printQuality` is implemented in Swift beyond the spec sample.
### Geometry & pixel integrity (SPEC §6, §15)
- 72 pt = 1 in. **Never** `backingScaleFactor` (`Geometry.swift`).
- `physicalInches = pixels / dpi`; `pointSize = physical * 72 * scaling`.
- Papers: A4/A3/A5/Letter/Legal/Tabloid/4x6/5x7. Unknown names fall back to A4, with substring match for `A4.Borderless`.
- Destination origin rounded to 0.001 pt to avoid fractional-device resampling while staying inside G1 ±0.1 mm.
- ImageIO load: `kCGImageSourceShouldCache=true`, `ShouldAllowFloat=false`. DPI from `kCGImagePropertyDPIWidth/Height`, default 72.
- `TargetPage.makeUnmanagedDeviceRGB`: redraw into `CGColorSpaceCreateDeviceRGB()` with interpolation **none**, antialias **off**, `shouldInterpolate: false`. SPEC §6.3: do **not** `CGImageCreateCopyWithColorSpace` into a calibrated space.
- Print draw (`TargetCanvasView.drawPrintedPage`): `interpolationQuality = .none`, `setShouldAntialias(false)`, `setAllowsAntialiasing(false)`, `setShouldSmoothFonts(false)`, fill paper white, `context.draw(cgImage, in: dest)`. GState saved/restored so page N does not inherit page N-1's transform.
- `PixelIntegrity.scanVerticalSeam`: any pixel on a red\|green seam that is not exactly left or right fails P1 (interpolation/antialias detected).
- Pagination: `knowsPageRange` 1…N, `rectForPage` = paper size, empty header/footer.
### ColorSync disable in TargetPrint (SPEC §7) — **different keys from ICCery**
`ColorMatching.swift`:
| User mode | `PMColorMatchingMode` value |
|-----------|-----------------------------|
| Unmanaged (profiling default) | `"APCustomColorMatching"` |
| ColorSync | `"APColorSync"` |
| Driver / Vendor | `"APPrinterExtension"` |
Injected into:
- `printInfo.dictionary()["PMColorMatchingMode"]`
- `printInfo.dictionary()["PMCustomColorMatchingProfile"]` = `""` when unmanaged else `"System"`
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
- nested `com.apple.print.printSettings` dictionary, same keys
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths.
Panel policy (`ColorMatching.configurePanel`):
- Always: copies, page range, preview
- If `allowBasicDriverChanges`: paper size, orientation, scaling
- If `lockColorManagement`: `stripColorMatchingAccessories` — walk `panel.accessoryControllers`, remove any whose class/title/nib contains "color matching" / "colour matching" / "colormatch", requiring `NSPrintPanelAccessorizing`. Stripped twice (configure + immediately before `runModal`) because system accessories install lazily.
### CUPS / PPD (`CUPSManager.swift`, `Bridging-Header.h`)
Bridging header:
```objc
#import <cups/cups.h>
#import <cups/ppd.h>
static inline const char *TPCupsGetPPD(const char *name) {
return cupsGetPPD(name); // deprecated; Swift overlay marks cupsGetPPD unavailable
}
```
- `cupsGetDests` / `cupsFreeDests` / `cupsGetOption` for `printer-uri-supported`, `device-uri`, `printer-make-and-model`.
- `TPCupsGetPPD` → read ISO-Latin-1 → `unlink` the temp PPD.
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
| Vendor | TargetPrint keys | ICCery macOS `lp` keys |
|--------|------------------|------------------------|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
| HP | `ColorModel=RGB`, `HPColorControl=Off` | (none auto) |
| Generic | any Color/Colour OpenUI choice in `{none,off,no,nocoloradjustment}` | `ColorCorrection=Uncorrected` / `StpColorCorrection=Uncorrected` |
A rewrite should apply **both** dictionaries (ICCery's empirically captured PDE keys **and** TargetPrint's SPEC keys).
Media/tray/quality discovery walks `*Keyword code/Title:` and `*OpenUI` translations (`media type`, `paper source`, `print quality`, …). Keywords tried:
- Media: `MediaType`, `CNIJMediaType`
- Tray: `InputSlot`, `EPIJ_FdSo`, `CNIJMediaSupply`
- Quality: `CNIJPrintQuality`, `EPIJ_Qual`, `cupsPrintQuality`, `PrintQuality`, `CNIJPrintMode2`, `Quality`, `StpQuality`
Canon quality is a triple (`CNIJPrintQuality` + `CNIJPrintMode2` + `CNIJPQualitySlider`) mapped in `PrintEngine.applyOptionalPPDKeys`.
PPD hex escapes (`<2F>``/`) decoded by `decodePPDString`.
### PrintEngine (`PrintEngine.swift`)
`makePrintInfo()`:
- `jobDisposition = .spool`
- margins 0, centering **off** (geometry is in the view), pagination `.clip`, `scalingFactor = 1.0`
- `NSPrinter(name: printerName)`
- paper size from `Geometry.paper(named:)`
- `ColorMatching.apply`
- `CUPSManager.namedQueue``vendorColorBypass` + optional media/tray/quality/resolution extras
- `canSpawnSeparateThread = false` on the operation
- `runModal(for: window, delegate:didRun:)` — exit code 0 on success **or** cancel (SPEC §4)
### How ICCery would invoke it (SPEC §16, `VENDOR.md`)
Not implemented in current ICCery Rust. Specified as:
```rust
let json = serde_json::to_string_pretty(&job)?;
let mut path = env::temp_dir();
path.push(format!("iccery_job_{}.json", uuid::Uuid::new_v4()));
fs::write(&path, json)?;
Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
// or vendored: src-tauri/targetprint/macos-{x86_64,aarch64,universal}/TargetPrint.app/Contents/MacOS/TargetPrint
.arg("--job")
.arg(&path)
.spawn()?; // fire-and-forget — do not wait
```
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
Suggested ICCery integration:
1. Keep current `lp` path as the headless/fast path (and the only path on Linux).
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
### TargetPrint vs ICCery macOS — decision table for the rewrite
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|---------|-------------------|-------------|------------------------|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` |
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges |
| AirPrint | none | detected + warned | Port detector into ICCery printer list |
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
---
## Issue cross-reference
| # | Title | Print relevance |
|---|-------|-----------------|
| 25 | Raw OS Printing Engine | Parent feature. ACPU-equivalent, no `window.print()`. |
| 26 | Windows GDI/ICM | `SetICMMode`, `DMICMMETHOD_NONE`, BGR DIB, `StretchDIBits`. |
| 27 | macOS/Linux CUPS | Originally `-o raw`. macOS later diverged to AP_* (#92/#188). |
| 28 | UI + IPC | `get_printers`, printer `<select>`, Print Target button, toasts. |
| 34 | Driver properties, trays, orientation, auto-fit | Windows dialog + scaler; Unix lpoptions; `PrintOptions`. |
| 36 | DEVMODE not applied | Full buffer + `dmDriverExtra` retained into `CreateDCW`. |
| 46 | Console windows on Windows | `CREATE_NO_WINDOW` on Argyll spawn, **not** on print. |
| 48 | Hide PPD checkbox on Windows | `#cupsOptionsGroup.hidden`. |
| 50 | Stage 2 badge cleanup | Removed ACPU / auto-fit badges. |
| 67 | Consolidate print commands | `get_printers` / `print_target_native` only. |
| 68 | printtarg JSON manifest | Gallery + per-page print consume `event: manifest`. |
| 92 | macOS CUPS raw spooler | `macos.rs` created; ColorSync research. Milestone 9. |
| 127 | Fetch Argyll + NSIS USB | Instrument USB, not printer drivers. |
| 188 | Cannot disable Epson/Canon CM on macOS | Preferences was System Settings. Now NSPrintPanel + SPI + AP_* + PPD bypass + capture. |
---
## Files (absolute)
```
/tmp/ICCery/src-tauri/src/print/mod.rs
/tmp/ICCery/src-tauri/src/print/macos.rs
/tmp/ICCery/src-tauri/src/print/windows.rs
/tmp/ICCery/src-tauri/src/print/unix.rs
/tmp/ICCery/src-tauri/src/print/tests.rs
/tmp/ICCery/src-tauri/src/commands.rs # get_printers, get_printer_capabilities,
# show_printer_properties, print_target_native
/tmp/ICCery/src-tauri/src/lib.rs # PrinterDevModeStore manage + command list
/tmp/ICCery/src-tauri/src/process_manager.rs # CREATE_NO_WINDOW for Argyll
/tmp/ICCery/src-tauri/src/calibration.rs # CREATE_NO_WINDOW for printcal
/tmp/ICCery/src-tauri/Cargo.toml # objc2* / windows features
/tmp/ICCery/src-tauri/windows/hooks.nsh # NSIS USB
/tmp/ICCery/src/js/printtarg.js
/tmp/ICCery/src/index.html # #rawPrintPanel
/tmp/ICCery/AGENTS.md # #188 contract
/tmp/ICCery-CPU/SPEC.md
/tmp/ICCery-CPU/VENDOR.md
/tmp/ICCery-CPU/Bridging-Header.h
/tmp/ICCery-CPU/Sources/main.swift
/tmp/ICCery-CPU/Sources/AppDelegate.swift
/tmp/ICCery-CPU/Sources/AppError.swift
/tmp/ICCery-CPU/Sources/Printing/ColorMatching.swift
/tmp/ICCery-CPU/Sources/Printing/PrintEngine.swift
/tmp/ICCery-CPU/Sources/Printing/CUPSManager.swift
/tmp/ICCery-CPU/Sources/Models/TargetJob.swift
/tmp/ICCery-CPU/Sources/Models/TargetPage.swift
/tmp/ICCery-CPU/Sources/Models/Geometry.swift
/tmp/ICCery-CPU/Sources/Models/PixelIntegrity.swift
/tmp/ICCery-CPU/Sources/Views/TargetCanvasView.swift
/tmp/ICCery-CPU/Sources/Controllers/PreviewWindowController.swift
```
+82
View File
@@ -0,0 +1,82 @@
# 15 — Stage 3: `chartread`, averaging, swatch grid
UI: `#stage-3`. Action: `#btnStartRead``run_chartread`.
## argv (`build_chartread_args`)
```
-v -u [-c port] [-Y l] basename
```
| Source | Flag |
|--------|------|
| always | `-v -u` |
| `#chartreadInstrumentSelect` value | `-c port` if non-empty **and not `"1"`** (#111) |
| Settings `enable_i1pro2_leds` | `-Y l` (default **off** for stock-Argyll compatibility) (#204) |
If `enable_i1pro2_leds` is omitted in the config, backend loads it from settings.
Process id: `chartread_${basename}`.
**Do not pass instlist device index as `-c`.** `instlist.port` is already the 1-based comm port. Port `1` means "default" — omit `-c` (#111).
## `instlist`
`detect_instruments` spawns `instlist` with **no args**, id `"instlist"`, cwd inherited. Fork emits JSON:
```json
{ "devices": [ { "name": "...", "type": "...", "port": 1, ... } ] }
```
Frontend prefers JSON; falls back to regex `^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?`. XY label if name/type matches `/spectro\s?scan|i1io/i` (`data-xy="1"`).
## State machine
`STATE`: `IDLE`, `CALIBRATING`, `AWAITING_STRIP`, `READING`, `ALL_STRIPS_READ`, `WARNING`, `PROMPT_CONTINUE`, `TABLE_PLACE_SHEET`, `TABLE_ALIGN`, `ERROR`, `FINISHED`.
Classifier is a **pure function** (`classifyChartreadLine`) with 39 tests. Order of matchers matters — see [05](05-argyll-fork.md) §12.6.
XY two-line prompts: line 1 `locate patch A1 with the sight,` then line 2 `then hit return to continue`. While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, continuation lines **stay sticky** in that state (#93).
`Please remove last sheet from table` is info-only (`isRemoveSheetNotice`); do not prompt.
Cancel on XY: send `q\n` then kill so the head parks.
## Averaging (#87, bugs #109/#110)
Each completed pass: `snapshot_ti3` copies `{basename}.ti3``{basename}_pass{N}.ti3` and **deletes** the canonical file. Stage 4 stays locked. Finish: `run_average -v pass1 pass2 … output.ti3` then `promote_ti3` if needed.
Do **not** overwrite the same `.ti3` in place (#109). Do **not** unlock Stage 4 after the first pass (#110).
## Swatch grid (#6, #178)
Listens `process:json_row`. Each patch is a 135° diagonal split: **top-left intended**, **bottom-right measured**.
`is_pad` spacers are skipped **only** when there is no measurement **and** device values are all zero. White reference patches (`-e`) may be flagged `is_pad` but have valid Lab — **render them** (#178).
Row order A→Z, patches 1→N left-to-right / top-to-bottom to match printtarg.
ΔE₀₀ from `delta_e.js` (`computeDeltaE00`). Traffic lights from settings:
- `< delta_e_good_max` (default 2.0) Good
- `< delta_e_warning_max` (default 5.0) Warning
- else Bad
`settings-saved` reclassifies live swatches. Thresholds must be ≥ 0 and `good < warning`.
## i1Pro 2 LEDs (#204)
Fork `-Y l` (not the `-L` from the original ticket). Silent no-op on instruments without `inst_stat_leds`.
| LED | Meaning |
|-----|---------|
| Flashing white | awaiting white-tile calibration |
| Flashing blue | ready for strip |
| Flashing red | misread |
| Flashing green | capture OK |
If an unpatched binary rejects `-Y l`, capture last stderr line and expand Process Output.
## Interactive buttons vs real keys
See [05](05-argyll-fork.md) §12. Real strip-mode keys are `f/b/n/d/q`, Space, Return, `y/n`. UI labels "Skip" / "Undo" send `s\n` / `u\n` which the **mock** understands; upstream strip mode treats unknown letters as trigger. Preserve current UI behaviour or document a protocol change — do not silently change what bytes are sent without updating tests.
+38
View File
@@ -0,0 +1,38 @@
# 16 — Stage 4: `colprof` (#7, #56, #176)
UI: `#stage-4`. Action: `#btnCreateProfile``run_colprof`.
## argv (`build_colprof_args`)
```
-v -a {algorithm} -q {quality} [-t intent] [-f [spec]] [-i illum] [-o obs] [-c inView] [-d outView] [-D desc] [-C copyright] basename
```
| UI | Field | Flag | Values |
|----|-------|------|--------|
| Algorithm | `algorithm` | `-a` | typically `l` (cLUT) |
| Quality | `quality` | `-q` | `l`/`m`/`h`/`u` |
| Intent | `intent` | `-t` | omitted if empty |
| FWA/OBA | `fwa` | `-f` | `none` → omit; empty → `-f` alone; else `-f D50\|D65\|path.sp` |
| Illuminant | `illuminant` | `-i` | override D50 |
| Observer | `observer` | `-o` | override 1931 2° |
| Input viewing | `input_viewing_cond` | `-c` | skip `none` |
| Output viewing | `output_viewing_cond` | `-d` | skip `none` |
| Description | `description` | `-D` | |
| Copyright | `copyright` | `-C` | |
Process id: `colprof_${basename}`.
Fork `colprof -u` JSON progress exists; **ICCery does not pass `-u`**. Spinner/stage label is driven from stdout text.
## FWA (#176)
`#colprofFwa`: D50 (recommended modern OBA paper / ISO 3664 M1), None, D65, Custom `.sp`. Custom browse uses `select_spectrum_file` (`*.sp`) — **not** `window.__TAURI__.dialog` (#210).
## IPC mismatch that broke Stage 4 (#56)
Early JS sent `{ basename, cwd }` while Rust expected `{ ti3_path, icc_path }`, and process ids disagreed so exit listeners never fired. **Config struct and process id must match on both sides.**
## After success
If Apply Calibration is on, run `applycal -a` on the new profile. Then enable `#btnGoToVerify`. Locate profile via `get_profile_path` / `resolve_profile_extension` (#69).
+66
View File
@@ -0,0 +1,66 @@
# 17 — Stage 5: verification, drift, install
UI: `#stage-5`. Actions: `#btnVerify``run_profcheck` then `extract_gamut`; `#btnInstallProfile``install_profile_to_system`.
## `profcheck` argv (`build_profcheck_args`)
```
-v -k -s -u {ti3} {icc}
```
`-u` is the fork JSON ΔE report. Also swap `.icc`/`.icm` if missing (#69). Process id: `profcheck_${ti3_path}` (note: contains path — listeners must use the same string).
## Parsing (`parseProfcheckReport`)
Prefer JSON summary (`avg`, `max`, `rms`, patch count). Fall back to legacy plain-text. If nothing parses, cards show `0.00` **and** a warning is appended to the process log (#179) — never silently zero.
## Verification bands (#95) — **not** the Stage 3 swatch thresholds
Stored on each `VerificationRecord.status`:
| avg ΔE₀₀ | Status | Badge |
|----------|--------|-------|
| < 1.0 | Excellent | `badge-excellent` |
| < 2.0 | Good | `badge-good` |
| < 3.5 | Acceptable | `badge-acceptable` |
| ≥ 3.5 | Warning | `badge-poor` |
Do **not** reuse `delta_e_good_max` / `delta_e_warning_max` (those are live swatch lights, defaults 2.0 / 5.0).
## History store (`quality_store.rs`)
Path: app data `verification_history.json`.
```
id: vr-<epoch_millis>-<seq>
profile_name, printer_name (wizardState.printerName or "Unknown")
avg_de, max_de, rms_de, patch_count, status, timestamp ISO-8601 UTC
```
Capacity **1000** (README; some older comments said 500). Evict oldest.
**Atomic write (#213):** write `.tmp` then `rename`. Non-atomic writes corrupted history on crash.
Serde: nested structs `snake_case`; Tauri command **arguments** `camelCase` (`savePath`, `record`, `profileName`).
## Drift UI
- SVG dual-series trend with shaded ICCery bands
- Consecutive-breach alert: ≥ 2 consecutive runs with status Warning, on **distinct calendar days** or **≥ 1 hour apart**
- Printer filter `#driftPrinterFilter`
- CSV export RFC-4180 (`export_verification_history_csv`)
- Clear history command
## `iccgamut`
```
-v -d 10 {resolved_profile}
```
`-d 10` is **surface density**, not a directory (#112). Never pass `-d 50.0` as a folder. Cwd = profile parent. Output `{stem}.gam` next to the profile.
See [18](18-gamut-viewer.md).
## Install profile (#223)
See [19](19-profile-install.md). Never move the working-directory artefact.
+829
View File
@@ -0,0 +1,829 @@
# 18 — 3D gamut viewer
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Exhaustive reading of the current implementation for a rewrite. All citations are
`path:start-end` against `/tmp/ICCery` unless noted. Issue numbers refer to
`/tmp/iccery-research/issues/`.
**Module map**
| File | Role |
|---|---|
| `src/js/gamut_viewer.js` (831 lines) | Scene, parser, mesh, lifecycle, controls |
| `src/js/gamut_viewer.test.js` | Parser + WebGL-heuristic unit tests (Node) |
| `src/js/CSS2DRenderer.js` | Vendored Three.js CSS2D addon (IIFE → `THREE.*`) |
| `src/js/OrbitControls.js` | Vendored Three r128 OrbitControls (IIFE → `THREE.OrbitControls`) |
| `src/js/vendor/quickhull.js` | Incremental 3D QuickHull fallback |
| `src/js/color_convert.js` | Lab → sRGB for per-vertex colour |
| `src/js/three.min.js` | Three.js **r128** (`const e="128"`) |
| `src/js/state.js` | `ensureGamutViewer` / `pauseGamutViewer` on stage change |
| `src/js/app.js` | GPU hints, **no** eager WebGL on `DOMContentLoaded` |
| `src/js/colprof.js` | Post-`colprof` `extract_gamut` + `loadGamutMesh` |
| `src/js/profcheck.js` | Post-verify `loadGamutMesh` |
| `src-tauri/src/commands.rs` | `build_iccgamut_args`, `extract_gamut`, `read_file_base64` |
| `src/assets/sRGB.gam` | Bundled full Argyll surface (448 verts / 892 faces) |
| `src-tauri/argyll/reference_gamuts/sRGB.gam` | Stub 8-cusp CGATS — **not** what the viewer loads |
---
## 1. Lifecycle (MUST NOT init WebGL on `DOMContentLoaded`)
This is the #225 contract. Eager `THREE.WebGLRenderer` + continuous rAF on a
hidden Stage 5 canvas respawns WKWebViews GPU helper on Monterey Intel: white
flash loop, then silent exit (Web Content process death, no `ICCery` crash
report).
### 1.1 What is forbidden
`src/js/app.js:130-137` explicitly does **not** call `initGamutViewer` in the
`DOMContentLoaded` `safeInit` batch:
```js
// Gamut Viewer is deferred until Stage 5 is shown (eager WebGL on launch
// respawns WKWebView on Monterey Intel).
safeInit('Stage 1 (Targen)', initTargen);
// ...
safeInit('Stage 5 (Profcheck)', initProfcheck); // profcheck UI only, not WebGL
```
`#stage-5` starts with `class="stage hidden"` (`src/index.html:901`). Hidden
containers report `clientWidth === 0`; the old code fell back to 500×400 and
still created a GPU context (#225 §7.1).
### 1.2 `ensureGamutViewer` — only when Stage 5 is shown
`src/js/state.js:90-94`:
```js
if (stageNumber === 5) {
ensureGamutViewer();
} else {
pauseGamutViewer();
}
```
This is the **only** production call site besides context-restore / fallback
reload. `resumeGamutViewer` is exported (`gamut_viewer.js:180-182`) but **never
imported**. Returning to Stage 5 re-enters `ensureGamutViewer`, which
`startAnimate()`s if already ready.
`ensureGamutViewer` (`gamut_viewer.js:149-174`):
1. Bail if `gamutViewerUnavailable` (WebGL feature-detect failed once — sticky).
2. If `gamutViewerReady`, just `startAnimate()` (no second renderer).
3. If `#stage-5` has class `hidden`, **return without creating WebGL**.
4. Defer actual `initGamutViewer()` by one `requestAnimationFrame` so layout has
a non-zero size after the `.hidden` class is removed.
5. Re-check hidden + ready flags inside the rAF callback.
`initGamutViewer` (`gamut_viewer.js:187-329`) is the real constructor:
- Re-entry guard: `gamutViewerReady || gamutViewerInitStarted`.
- Requires `#gamutViewerContainer` and global `THREE`.
- Feature-detects WebGL **before** `new THREE.WebGLRenderer` (`webglAvailable`,
lines 37-46): tries `webgl2`, then `webgl`, then `experimental-webgl`.
- On failure: sets `gamutViewerUnavailable = true`, logs
`WEBGL_UNAVAILABLE_MESSAGE`, paints fallback, returns. Never throws into
`safeInit`.
- Constrained-GPU path (`isLikelyConstrainedGpu`, lines 56-79 +
`setGpuHints` from `app.js:96-100`):
```js
renderer = new THREE.WebGLRenderer({
antialias: !lowPower,
alpha: false, // opaque; matches scene.background 0x0e0e14
powerPreference: lowPower ? 'low-power' : 'default',
failIfMajorPerformanceCaveat: false,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, lowPower ? 1 : 2));
```
Arch preference: backend `get_app_info.arch` beats UA. Safari reports
`navigator.platform === "MacIntel"` on Apple Silicon; treating that as Intel
would wrongly drop antialias on M-series. Heuristic: `x86_64`/`x86`/`ia32`
from backend → constrained; else UA `ARM|Apple Silicon|aarch64` → not;
else Intel Mac UA + `macosMajor < 13` (or unknown major) → constrained.
- Default size if container is still 0: **500×400** (`gamut_viewer.js:214-215`).
Should not trigger if the hidden-stage guard works.
### 1.3 Pause on leave
`pauseGamutViewer``stopAnimate()` (`animationRunning = false`). The rAF loop
(`gamut_viewer.js:334-343`) returns immediately if the flag is false, so a lost
or hidden context does not spin:
```js
function animate() {
if (!animationRunning) return;
requestAnimationFrame(animate);
if (contextLost || !renderer) return;
if (controls) controls.update();
if (renderer && scene && camera) {
renderer.render(scene, camera);
if (labelRenderer) labelRenderer.render(scene, camera);
}
}
```
**Does not dispose** the renderer on leave. Scene, GPU context, and meshes
survive. That is intentional: re-entering Stage 5 is cheap, and `loadGamutMesh`
from Stage 4 can populate the scene if the user already visited Stage 5.
### 1.4 Context-lost handler
`gamut_viewer.js:238-252`:
```js
renderer.domElement.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // allow restore
contextLost = true;
stopAnimate();
logger.error('WebGL context lost', 'GamutViewer');
showGamutFallback(container, '3D view lost its GPU context. Profiling stages still work.', {
reloadable: true,
});
});
renderer.domElement.addEventListener('webglcontextrestored', () => {
logger.warn('WebGL context restored — rebuilding viewer', 'GamutViewer');
contextLost = false;
disposeViewer();
ensureGamutViewer();
});
```
`disposeViewer` (`gamut_viewer.js:115-143`) disconnects `ResizeObserver`,
`renderer.dispose()`, removes both canvases, nulls scene/camera/controls/meshes,
clears `gamutViewerReady` / `gamutViewerInitStarted` / `contextLost`.
**Not reset by dispose:** `gamutViewerUnavailable`, `togglesWired`, `gpuHints`.
`togglesWired` is OK (listeners live on HTML checkboxes, not the canvas).
`gamutViewerUnavailable` means a “Reload 3D view” button after a *feature-detect*
failure would no-op — but that path does not show the button (`reloadable`
defaults false). Init-throw and context-lost do show it.
### 1.5 Monterey WKWebView (#225) — adjacent host fixes (not in the JS viewer)
Fix 3 (this module) is the crash stop. Host-side (for rewrite awareness):
- Window `visible: false` until double-rAF + `show_main_window` (`app.js:143-150`).
- Dark `#1A1A22` window / WKWebView backing (`tauri.conf.json`, `macos_webview.rs`).
- `minimumSystemVersion` 12.0; Intel Monterey < 13 gets a one-shot notice
(`app.js:30-45`).
- `visibilitychange` / `pagehide` logged (`app.js:74-79`).
Rewrite **must** keep: no WebGL until Stage 5 visible; pause rAF on leave;
feature-detect; context-lost fallback; `alpha: false`; DPR cap on constrained
GPU; do not treat MacIntel UA as Intel when backend arch is `aarch64`.
---
## 2. Scene graph
Coordinate convention (commented at `gamut_viewer.js:346-347` and `_buildGeometry`):
```
X = a* (128 → +128) green ← → red
Y = L* (0 → 100) lightness, up
Z = b* (128 → +128) blue ← → yellow
```
### 2.1 Camera
- `PerspectiveCamera(45, aspect, 0.1, 2000)`
- Home position `(180, 120, 180)`
- `lookAt(0, 50, 0)` — centre of the L* axis, not the origin
- `OrbitControls.target = (0, 50, 0)`, `enableDamping = true`, `dampingFactor = 0.05`
- Touch override (`gamut_viewer.js:272-275`): `ONE: ROTATE`, `TWO: DOLLY_PAN`
(same as OrbitControls defaults; #117 says do not change this).
- `renderer.domElement.style.touchAction = 'none'` to stop the webview scrolling.
`resetCamera` (`gamut_viewer.js:676-682`) restores position + target. OrbitControls
also has its own `reset()` using `position0/target0`, but the app never calls it;
the custom function writes the same home pose.
### 2.2 Lights
No shadows, no environment map, no fog.
| Light | Colour | Intensity | Position |
|---|---|---|---|
| AmbientLight | `0xffffff` | 0.75 | — |
| DirectionalLight | `0xffffff` | 0.80 | `(150, 250, 150)` |
| DirectionalLight | `0xffffff` | 0.35 | `(-120, -80, -120)` |
Lambert materials + these lights are what make per-vertex Lab colour readable.
A rewrite that switches to MeshBasicMaterial would lose the slight modelling;
MeshStandardMaterial would need different lighting.
### 2.3 Axis scaffold (`buildAxisScaffold`, `gamut_viewer.js:349-423`)
Replaces `THREE.AxesHelper` (#185). One `THREE.Group` (`axisScaffoldGroup`):
1. **Bounding box**`BoxGeometry(256, 100, 256)``EdgesGeometry`
`LineSegments`, colour `0x232336`, opacity 0.9, **positioned at `(0, 50, 0)`**
so it covers L* 0100, a*/b* ±128.
2. **Ground grid**`GridHelper(256, 16, 0x1e1e2e, 0x1a1a28)` at `y = 0`
(L*=0). #185 said “remove GridHelper”; the implementation kept a custom one.
3. **Axis lines** — L* grey `0xcccccc` `(0,0,0)→(0,100,0)`; a* and b* `0x99bbcc`
spanning ±128.
4. **Ticks** — L* at 25/50/75/100 (crosses ±4 on X and Z); a* and b* at
±128/±64 (vertical ticks ±3). **No tick at 0** on a*/b*.
5. **CSS2D labels** (only if `THREE.CSS2DObject` exists):
| Text | Position | Class |
|---|---|---|
| `L*` | `(0, 110, 0)` | `gamut-axis-primary` |
| `+a* (Red →)` | `(140, 0, 0)` | `gamut-axis-a-pos` |
| `← a* (Green)` | `(-140, 0, 0)` | `gamut-axis-a-neg` |
| `+b* (Yellow →)` | `(0, 0, 140)` | `gamut-axis-b-pos` |
| `← b* (Blue)` | `(0, 0, -140)` | `gamut-axis-b-neg` |
| `25` / `50` / `75` / `100` | `(10, v, 0)` | `gamut-tick-label` |
No numeric labels on a*/b* ticks. Colours are CSS (`main.css:1667-1691`):
a+ salmon, a green, b+ yellow, b blue.
Labels are **children of `axisScaffoldGroup`**. `toggleAxes` sets
`axisScaffoldGroup.visible`. WebGL descendants honour group visibility; the
CSS2DRenderer (`CSS2DRenderer.js:87-89`) only tests `object.visible` on the
label itself, **not ancestors**. Toggling axes off can leave CSS2D labels
on-screen. Rewrite should walk parents or hide the label DOM.
### 2.4 CSS2D overlay
`CSS2DRenderer.js` is Three.js `examples/jsm/renderers/CSS2DRenderer.js`
rewritten as an IIFE that assigns `THREE.CSS2DObject` / `THREE.CSS2DRenderer`
(`CSS2DRenderer.js:157-159`). Loaded as a classic script after `three.min.js`
(`index.html:9-11`).
Init (`gamut_viewer.js:255-263`): absolute, `top/left 0`, `pointer-events: none`
so orbit still hits the WebGL canvas. Rendered every frame after the WebGL pass.
### 2.5 Profile mesh (`currentProfileMesh`)
Solid `THREE.Mesh`, `MeshLambertMaterial({ vertexColors: true, transparent: true,
opacity: 0.88, side: THREE.DoubleSide })`. Native `.gam` faces when present;
QuickHull fallback otherwise. See §45.
### 2.6 sRGB reference (`sRgbGroup`)
A `THREE.Group` of two children sharing one `BufferGeometry` (#185 component 2):
1. Faint fill: `MeshLambertMaterial({ color: 0x8899bb, opacity: 0.07,
transparent, DoubleSide, depthWrite: false })`.
2. Structural outline: `EdgesGeometry(geometry, 15)` (15° coplanar threshold)
+ `LineBasicMaterial({ color: 0x6688aa, opacity: 0.55 })`.
Loaded from `fetch('assets/sRGB.gam')` at the end of `initGamutViewer`
(`loadSrgbReferenceGamut`, lines 742-752). **Adobe RGB was specified in #9 and
never shipped.**
### 2.7 Resize
`ResizeObserver` on `#gamutViewerContainer` updates camera aspect, renderer size,
and labelRenderer size. No `window.resize` listener.
### 2.8 Background
`scene.background = 0x0e0e14` matches `#gamutViewerContainer` CSS. `alpha: false`
on the renderer is required so WKWebView does not composite a white underlay.
---
## 3. `.gam` file parser
`parseGamutFile` (`gamut_viewer.js:447-509`) is the post-#179 parser. The
previous `parseCGATS` only read the first `BEGIN_DATA` (vertices), discarded
the triangle table, then handed `{L,a,b}` objects to QuickHull which indexes
`.x/.y/.z` → empty hull → blank scene.
### 3.1 Real Argyll format (`src/assets/sRGB.gam`)
Header (not parsed; ignored because `dataStarted` is false):
```
GAMUT
DESCRIPTOR "Argyll Gamut surface poligon data"
ORIGINATOR "Argyll CMS gamut library"
COLOR_REP "LAB"
GAMUT_CENTER "50.000000 0.000000 0.000000"
CUSP_RED / CUSP_YELLOW / … / CUSP_MAGENTA
# First come the triangle verticy location
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
VERTEX_NO LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 448
BEGIN_DATA
0 53.23738 78.28787 62.14806
END_DATA
# And then come the triangles
NUMBER_OF_FIELDS 3
BEGIN_DATA_FORMAT
VERTEX_0 VERTEX_1 VERTEX_2
END_DATA_FORMAT
NUMBER_OF_SETS 892
BEGIN_DATA
13 1 46
END_DATA
```
Two `BEGIN_DATA``END_DATA` blocks. Block 1 = vertices (`index L a b`).
Block 2+ = 0-based triangle indices. Issue #179s printer sample
(`xp_55_koala_satin.gam`) is the same shape: 739 verts, 1474 faces.
### 3.2 Algorithm (quote)
```js
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
if (trimmed === '') continue;
if (trimmed.toUpperCase() === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
if (trimmed.toUpperCase() === 'END_DATA') { dataStarted = false; continue; }
if (!dataStarted) continue;
const parts = trimmed.split(/\s+/).map(Number);
if (dataBlock === 1) {
// Vertex format: index L a b (index is usually ignored)
const [_, L, a, b] = parts; // VERTEX_NO discarded
vertices.push([L, a, b]);
} else {
faces.push([parts[0], parts[1], parts[2]]);
}
```
Rules:
- Hash comments stripped **even inside data blocks** (`# inline comment` test).
- `BEGIN_DATA` / `END_DATA` matched case-insensitively.
- Header keys (`COLOR_REP`, `NUMBER_OF_SETS`, `BEGIN_DATA_FORMAT`, …) skipped.
- Non-numeric data lines → warning, skip.
- Vertex arity < 4 or face arity < 3 → warning.
- Lab plausibility warning (does **not** drop the vertex):
`L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128`.
Bundled sRGB cusp blue is `b* = -126.4162` (inside); a printer gamut can
exceed ±128.
- Multiple face blocks are concatenated (`dataBlock >= 2`). Dual-table test
covers this.
- Vertices with no faces → warning *“will compute convex hull on the fly.”*
- Zero `BEGIN_DATA` → warning *“file may be empty or not a valid Argyll .gam”*.
`VERTEX_NO` is discarded. Faces are assumed to index **push order** (Argyll
writes 0..N-1 sequentially). A non-dense index column would silently mis-bind.
### 3.3 Lab vs XYZ
`COLOR_REP "LAB"` is **not read**. Columns are always treated as L, a, b.
`iccgamut` default is Lab. If a rewrite (or a user) ever passed `iccgamut -x`
(XYZ), the viewer would plot XYZ as if it were Lab and colour it through
`labToSrgb`. There is no XYZ path.
### 3.4 Stub vs real sRGB.gam
`src-tauri/argyll/reference_gamuts/sRGB.gam` is **not** an Argyll gamut surface:
```
CGATS.17
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
INDEX LAB_L LAB_A LAB_B
END_DATA_FORMAT
NUMBER_OF_SETS 8
BEGIN_DATA
0 0.0 0.0 0.0 # black
1 100.0 0.0 0.0 # white
2 53.2 80.1 67.2 # red cusp (approx)
END_DATA
```
8 points, **no face table**. The parser would emit 8 vertices + the hull-fallback
warning; QuickHull of 8 cusps is a coarse octahedron, not sRGB. The viewer
never loads this file. It `fetch`es `src/assets/sRGB.gam` (448/892, produced by
`iccgamut` on 2026-08-25). Rewrite: either delete the stub or generate it the
same way as the asset, and do not assume `reference_gamuts/` is the runtime
source.
---
## 4. Mesh generation: QuickHull vs ConvexGeometry
### 4.1 History
| Issue | What happened |
|---|---|
| #9 | Planned Delaunay on Lab point cloud + sRGB/AdobeRGB wireframes. |
| #57 | Viewer was a dead panel; v0.2 used 2D Delaunay on (a*, b*) flattening L*. |
| #89 | Replaced Delaunay with inlined 3D QuickHull (~270 lines in `gamut_viewer.js`); touch OrbitControls. `delaunator.min.js` deleted. |
| #117 | Proposed replacing the inline hull with Three r128 `ConvexGeometry` / `ConvexHull`, **or** vendoring `quickhull3d`. Optional. |
| #179 | Real `.gam` files already contain the surface triangulation. Parser was throwing it away and hulling `{L,a,b}` objects → empty mesh. |
**Current state:** native face indices are the primary path. QuickHull is only
the no-faces fallback. There is **no** `ConvexGeometry.js` / `ConvexHull.js` in
the tree (`src/js/vendor/` contains only `quickhull.js`). #117 was closed but
the code took the “vendor quickhull3d-like ESM” option, not Threes addon.
### 4.2 `_buildGeometry` (`gamut_viewer.js:515-568`)
```js
if (vertices.length < 4) return null;
if (faces.length > 0) {
// Native mesh: position [a, L, b] per vertex, Uint32 index from .gam
posArr[i*3] = a; // X
posArr[i*3+1] = L; // Y
posArr[i*3+2] = b; // Z
geometry.setIndex(new THREE.BufferAttribute(idxArr, 1));
geometry.computeVertexNormals();
} else {
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // a, L, b
const hullFaces = computeQuickHull(pts);
// Dedup by `${x}_${y}_${z}` string key while expanding hull faces
// Then rewrite `vertices` from the position attribute as [L, a, b]
}
```
Critical #179 fix for the fallback: points **must** be `{x,y,z}` not `{L,a,b}`.
`quickhull.js:44` uses `pts[i].x`; undefined x made `minX === maxX` and returned `[]`.
### 4.3 `computeQuickHull` (`src/js/vendor/quickhull.js`)
Classic incremental convex hull (150 lines):
1. O(n²) duplicate filter, eps `1e-5` (#117 risk: density-50 clouds freeze here).
2. Extreme points: min/max X, then furthest from that line, then furthest from
that plane → initial tetrahedron.
3. Outward winding via tetrahedron **centroid** (`createFace` flips if the
centroid is in front). Horizon winding is recovered from this centroid, not
the growing hull (#117 risk for re-entrant sets — moot for a true convex hull).
4. For each remaining point: collect visible faces (`n·(p-a) > 1e-9`), build
horizon as edges that appear once (`uId+'_'+vId` via `pts.indexOf`, another
O(n)), deactivate visible faces, fan new faces from horizon to the point.
5. Return `faces.filter(f => f.active)` as `{a,b,c}` point references.
A convex hull of a printer gamut **fills concavities** (dark cyan/magenta
indentations that #89 wanted to preserve). That is why native `.gam` faces are
the correct primary path: `iccgamut` already computed the (possibly non-convex)
surface. Hull is only for vertex-only clouds (the 8-cusp stub, or a future
`.ti3` point cloud).
`EdgesGeometry(geometry, 15)` on the sRGB mesh strips coplanar internal
triangle edges. Threshold 15° is a magic number from #185.
---
## 5. Per-vertex colouring from Lab
`_renderProfileGamut` (`gamut_viewer.js:623-667`) — **profile only**, not sRGB
reference (reference stays steel-blue `0x8899bb` / `0x6688aa`).
```js
const posAttr = geometry.getAttribute('position'); // laid out [a, L, b]
const colorArr = new Float32Array(posAttr.count * 3);
for (let i = 0; i < posAttr.count; i++) {
const a_star = posAttr.getX(i);
const L_star = posAttr.getY(i);
const b_star = posAttr.getZ(i);
const [r, g, b] = labToSrgb(L_star, a_star, b_star);
colorArr[i*3] = r / 255;
colorArr[i*3+1] = g / 255;
colorArr[i*3+2] = b / 255;
}
geometry.setAttribute('color', new THREE.BufferAttribute(colorArr, 1));
material = new THREE.MeshLambertMaterial({ vertexColors: true, opacity: 0.88, … });
```
`labToSrgb` (`color_convert.js:9-45`):
- D50 white `Xn=0.9642, Yn=1, Zn=0.8249` (ICC PCS).
- CIE Lab → XYZ (standard `δ = 6/29` piecewise cube).
- XYZ D50 → linear sRGB via a **Bradford-adapted D50→D65** matrix (not a raw
D65 sRGB matrix — correct for ICC Lab).
- sRGB gamma, clamp 0255, `Math.round`.
Out-of-gamut Lab (printer vertices outside sRGB) clips to the sRGB cube. That
is the intended diagnostic: the blob is “true colour” where representable and
clips where the printer exceeds sRGB — which is exactly when it pokes out of
the sRGB wireframe.
`colprof.js:206` still calls `loadGamutMesh(gamFilePath, 0x3b82f6)`. The second
arg is **ignored** (flat-blue leftover from pre-#185). Rewrite: drop it.
---
## 6. Controls
### 6.1 HTML overlay (`index.html:980-1038`)
Glassmorphic panel, `position: absolute; top/right: 12px` over
`#gamutViewerWrap`. Three layers, each a toggle + opacity slider:
| Control | Id | Default | Handler |
|---|---|---|---|
| Profile visibility | `chkProfileGamut` | checked | `toggleProfileGamut` |
| Profile opacity | `rngProfileOpacity` | 0.88 | `setProfileOpacity` |
| sRGB visibility | `chkSrgbReference` | checked | `toggleSrgbReference` |
| sRGB opacity | `rngSrgbOpacity` | 0.55 | `setSrgbReferenceOpacity` |
| Axes visibility | `chkLabAxes` | checked | `toggleAxes` |
| Axes opacity | `rngAxisOpacity` | 0.80 | `setAxisOpacity` |
| Reset | `btnGamutResetCamera` | — | `resetCamera` |
Hint text: “Drag to rotate • Scroll to zoom • Right-drag to pan” /
“Press R to reset”.
Wired once in `_wireToggles` (`gamut_viewer.js:791-831`). `togglesWired` is
never cleared on dispose.
### 6.2 Opacity idiosyncrasy (`setSrgbReferenceOpacity`, lines 700-710)
```js
child.material.opacity = child.material.opacity >= 0.5
? Math.max(0.05, Math.min(1, opacity)) // treated as edges
: Math.max(0.02, Math.min(0.2, opacity * 0.2)); // treated as fill
```
Initial edges 0.55 ≥ 0.5, fill 0.07 < 0.5. **If the user drags the slider below
0.5, edges flip into the fill branch permanently** (next input sees 0.1 < 0.5).
Rewrite should tag the two materials, not infer role from current opacity.
`setProfileOpacity` also sets `transparent = opacity < 1`. `setAxisOpacity`
walks all children with a `.material` (lines, grid) but CSS2D labels have none.
### 6.3 Reset (R)
Two mechanisms:
1. Button `btnGamutResetCamera`.
2. `stage5.addEventListener('keydown', …)` when key is `r`/`R` and stage is not
hidden (`gamut_viewer.js:822-829`).
`_onKeyDown` (`gamut_viewer.js:730-734`) is **dead code** — never registered.
`#stage-5` has **no `tabindex`**, so it will not receive `keydown` unless a
focusable descendant (slider, checkbox, button, or a form control earlier in
Stage 5) is focused. The button is the reliable path. Rewrite: listen on
`document` while Stage 5 is active, ignore events from text inputs.
### 6.4 Touch
Vendored `OrbitControls.js` is Three r128 (IIFE, assigns `THREE.OrbitControls`
and `THREE.MapControls` at the bottom). Touch is fully implemented
(`onTouchStart/Move/End`, `passive: false`, `preventDefault` on start).
Mapping used by the viewer:
- 1 finger → rotate
- 2 fingers → dolly + pan
Mouse: left rotate, wheel dolly, right pan. `renderer.domElement.style.touchAction
= 'none'` is required on WKWebView / mobile.
`OrbitControls` is **not** an ES module; it depends on global `THREE` from
`three.min.js` r128. A rewrite that npm-installs a newer three must vendor a
matching OrbitControls (and CSS2DRenderer) — the current files are frozen to r128.
---
## 7. `iccgamut` CLI flags
### 7.1 Current argv (#112 fix)
`commands.rs:684-690`:
```rust
pub fn build_iccgamut_args(resolved_path: &str) -> Vec<String> {
vec![
"-v".to_string(),
"-d".to_string(),
"10".to_string(),
resolved_path.to_string(),
]
}
```
Locked by `test_build_iccgamut_args` (`commands.rs:1475-1478`):
`["-v", "-d", "10", "/path/to/profile.icc"]`.
Meaning:
| Flag | Meaning |
|---|---|
| `-v` | Verbose (stdout into the process log via ProcessManager). |
| `-d 10` | **Surface point density**, typical useful range ~110. Not a directory. |
| `{profile}` | Resolved `.icc`/`.icm` path. |
| *(cwd)* | Profile parent dir, so `{basename}.gam` lands next to the profile. Set via `ProcessManager::spawn(..., cwd)`, **not** via `-d`. |
| no `-w` | VRML not required; `.gam` is the default. |
`extract_gamut` (`commands.rs:694-731`) also swaps `.icc``.icm` if the given
path does not exist, process id `iccgamut_{basename}`.
### 7.2 The `-d 50.0` bug (#112)
Pre-fix argv was `-v -d 50.0 {profile}`. Density 50 produces thousands of
vertices; QuickHulls O(n²) duplicate filter then froze the UI. There was
historical confusion that `-d` meant “directory”. Directory is `cwd`. Do **not**
pass the parent path as `-d`. Do **not** revert to 50.
#179s real printer file was generated with `-v -d 10` (739 verts / 1474 faces,
43.6 KB) — the density we want.
### 7.3 When it runs
1. **Stage 4 success** (`colprof.js:173-174, 197-215`): `triggerGamutExtraction`
listens for `process:exit` on `iccgamut_{basename}`, then
`loadGamutMesh(cwd/basename.gam)`. This often runs **while Stage 5 is still
hidden**, so `scene` is null and `_renderProfileGamut` returns null. The
`.gam` file is still written to disk.
2. **Stage 5 verify success** (`profcheck.js:626-635`): `loadGamutMesh` again.
This is the path that actually puts the printer mesh on screen, assuming the
user has opened Stage 5 (viewer inited) and then clicked Verify.
If the user opens Stage 5 *before* verifying, they see axes + sRGB only.
Navigating to Stage 5 does **not** auto-load an existing `{basename}.gam`.
Rewrite should load it in `ensureGamutViewer` once basename/cwd are known.
---
## 8. Fallback UI when WebGL is missing
`showGamutFallback` (`gamut_viewer.js:81-102`) wipes
`#gamutViewerContainer.innerHTML` and inserts:
```html
<div class="gamut-webgl-fallback" role="status">
<p>…message…</p>
<!-- optional --> <button class="secondary btn-md">Reload 3D view</button>
</div>
```
CSS (`main.css:1494-1511`): flex-centred, min-height 280px, same `#0e0e14`
background, muted text. Controls panel is a **sibling** of the container
(`#gamutViewerWrap` > container + panel), so the legend remains visible over
an empty/fallback view — another rewrite nicety: hide or disable it.
Messages:
| Trigger | Copy | Reload? |
|---|---|---|
| `webglAvailable() === false` | `WEBGL_UNAVAILABLE_MESSAGE` = “3D gamut viewer requires WebGL; the rest of ICCery still works.” | no |
| `initGamutViewer` throw | same `WEBGL_UNAVAILABLE_MESSAGE` | yes |
| `webglcontextlost` | “3D view lost its GPU context. Profiling stages still work.” | yes |
Reload: `disposeViewer(); ensureGamutViewer();`. Feature-detect failure sets
`gamutViewerUnavailable`, so a hypothetical reload would no-op until a full
page reload.
Intel Monterey < 13 also gets a session-once wizard banner
(`app.js:30-45`): “On this Mac the 3D gamut view may be unavailable…”
Init catch path removes a half-attached `renderer.domElement` before showing
fallback (`gamut_viewer.js:321-327`).
---
## 9. Tests
`src/js/gamut_viewer.test.js` — Node-runnable ESM (`node src/js/gamut_viewer.test.js`).
#212: previously crashed with `window is not defined` because `gamut_viewer.js`
top-level-dereferenced `window.__TAURI__.core`. Now guarded
(`gamut_viewer.js:5`):
```js
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
? window.__TAURI__.core.invoke : null;
```
The test file polyfills `globalThis.window` / `document` **before** a dynamic
`await import('./gamut_viewer.js')` (static import is hoisted and would still
lose the race). Canvas `getContext` returns `null` so `webglAvailable()` is
false in Node.
Cases (`runAll`):
| Test | Asserts |
|---|---|
| `testParseGamutBasic` | 4 verts, 2 faces, 0 warnings |
| `testParseGamutDualTable` | two face `BEGIN_DATA` blocks concatenate to 2 faces |
| `testParseGamutWithComments` | `#` lines + `# inline comment` stripped |
| `testWebglAvailableFalseWithoutContext` | no context / no document → false |
| `testWebglAvailableTrueWithWebgl` | `getContext('webgl')` → true |
| `testConstrainedGpuPrefersBackendArch` | `aarch64` + MacIntel UA → **not** constrained; `x86_64` → constrained |
| `testConstrainedGpuIgnoresAppleSiliconUa` | ARM UA → not constrained |
| `testConstrainedGpuIntelMac` | Monterey Intel UA → constrained; x86_64 on Ventura still constrained |
| `testEnsureGamutViewerNoopsWithoutDom` | no Stage 5 / no THREE → `isGamutViewerReady() === false` |
| `testFallbackMessage` | copy contains “requires WebGL” |
**Not tested:** real `sRGB.gam` golden parse (448/892), Lab-bounds warnings,
empty file, XYZ, QuickHull tetrahedron/cube fixtures (#117 AC),
`labToSrgb` colours, `loadGamutMesh`, context-lost, opacity slider, R-key,
`build_iccgamut_args` is a **Rust** test not a JS one.
Auto-run:
```js
if (typeof process !== 'undefined' && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
runAll(); // throws if any fail → non-zero exit
}
```
---
## 10. Idiosyncrasies / rewrite traps
1. **Do not create WebGL on `DOMContentLoaded`.** Stage 5 starts `.hidden`.
`ensureGamutViewer` + one rAF + hidden-class re-check is the contract (#225).
2. **Pause rAF on leave; do not necessarily dispose.** Scene stays so Stage 4s
late `loadGamutMesh` can work *if* Stage 5 was visited first — which it
usually is not. Safer rewrite: queue the `.gam` path and load on first
`ensureGamutViewer`.
3. **Native faces > hull.** A convex hull of Lab points is the wrong surface
for a printer gamut. Only hull when the face table is missing.
4. **Position layout is `(a, L, b)` not `(L, a, b)`.** Colour, hull fallback,
and CSS2D all depend on this. Mixing them was the #179 blank-scene bug.
5. **`VERTEX_NO` is ignored; faces are 0-based push-order.** Fine for Argyll.
6. **`COLOR_REP` is ignored.** Lab only.
7. **Two sRGB.gam files, only one is real.** Runtime = `src/assets/sRGB.gam`
(448/892). `src-tauri/argyll/reference_gamuts/sRGB.gam` is an 8-point stub.
8. **No Adobe RGB overlay** despite #9.
9. **`loadGamutMesh(path, 0x3b82f6)`** — extra colour arg is dead.
10. **sRGB opacity slider infers fill vs edges from `opacity >= 0.5`.** Breaks
after the user goes below 0.5.
11. **CSS2D labels ignore parent `visible`.** Axis toggle is incomplete.
12. **R-key is bound to `#stage-5` without `tabindex`.** `_onKeyDown` is unused.
13. **Duplicate CSS** for `#gamutViewerContainer`: `main.css:835-842`
(`height: 400px; background: #111116`) and `main.css:1480-1486`
(`min-height: 420px; background: #0e0e14`). Later rules win for
background; `height: 400px` + `min-height: 420px` → 420px.
14. **Three r128 via `<script>` + IIFE addons.** Not ESM. `gamut_viewer.js` is
ESM and talks to `window.THREE`. A bundler rewrite must keep that seam or
import matching addons.
15. **`invoke` is null outside Tauri.** `loadGamutMesh` will throw if called
from tests / a browser without the polyfill.
16. **`togglesWired` / `gamutViewerUnavailable` survive `disposeViewer`.**
17. **`GridHelper` still present** after #185 said to remove it.
18. **QuickHull O(n²) duplicate filter** — keep density at 10, never 50 (#112).
19. **`atob(read_file_base64)`** assumes the `.gam` is ASCII. It is (CGATS).
Do not switch to a UTF-16 profile dump without a TextDecoder.
20. **DoubleSide Lambert** hides winding errors in Argylls triangle table.
If a rewrite uses FrontSide, audit winding.
21. **`failIfMajorPerformanceCaveat: false`** — accept software GL rather than
crash Monterey.
22. **No `resumeGamutViewer` call site.** `ensureGamutViewer` already restarts
the loop when `gamutViewerReady`.
23. **Feature-detect prefers webgl2** but r128 `WebGLRenderer` still opens a
WebGL1 context unless `r128` is given `{ capability: … }`. Detecting webgl2
does not mean Three uses it.
24. **Stage 5 verify is what actually shows the printer mesh**, not Stage 4
extraction and not merely opening the viewer.
25. **Cusp vertices in the header** (`CUSP_RED` etc.) are not used. They are
duplicated as the first few `BEGIN_DATA` rows. A rewrite could label cusps
but the current viewer does not.
---
## 11. Public API surface (for a rewrite to preserve)
```js
setGpuHints(hints) // { arch, os, macosMajor }
isGamutViewerReady()
webglAvailable(doc?)
isLikelyConstrainedGpu(hints?)
ensureGamutViewer() // Stage 5 entry
pauseGamutViewer() // Stage 5 leave
resumeGamutViewer() // unused
initGamutViewer() // internal-ish but exported
parseGamutFile(text) → { vertices, faces, warnings }
resetCamera()
setProfileOpacity(0..1)
setSrgbReferenceOpacity(0..1)
setAxisOpacity(0..1)
loadSrgbReferenceGamut()
loadGamutMesh(gamFilePath) → Mesh|null
toggleSrgbReference(visible)
toggleProfileGamut(visible)
toggleAxes(visible)
WEBGL_UNAVAILABLE_MESSAGE
```
HTML ids that `_wireToggles` hard-codes: `chkProfileGamut`, `chkSrgbReference`,
`chkLabAxes`, `btnGamutResetCamera`, `rngProfileOpacity`, `rngSrgbOpacity`,
`rngAxisOpacity`, `gamutViewerContainer`, `stage-5`.
---
## 12. Issue → code checklist
| Issue | Status in tree |
|---|---|
| #9 3D Lab viewer | Shipped; no Adobe RGB; Delaunay replaced. |
| #57 dead panel | `extract_gamut` + `loadGamutMesh` wired from colprof + profcheck. Stage 4 load still races init. |
| #89 3D hull + touch | Hull vendored; OrbitControls touch live; Delaunay gone. |
| #112 `-d 50` | Fixed to `-d 10`; Rust unit test. |
| #117 ConvexGeometry | Not done; `vendor/quickhull.js` instead. Native faces make this low-priority. |
| #179 blank mesh + 0.00 ΔE | `parseGamutFile` uses both tables; points passed as `{x,y,z}`. (Profcheck regex is out of scope here.) |
| #185 axes / EdgesGeometry / vertex colour / legend | All present (GridHelper kept; CSS2D parent-visibility bug). |
| #212 Node test crash | Polyfill + dynamic import + `typeof window` guard. |
| #225 Monterey WebGL | Lazy ensure, feature-detect, pause rAF, context-lost, low-power flags. |
+75
View File
@@ -0,0 +1,75 @@
# 19 — System profile installation (#223)
Command: `install_profile_to_system(profile_path, options) → InstallResult`.
**Copies** (never moves, never deletes) the working `.icc`/`.icm` into the OS colour store. The wizard artefact in `cwd` must remain so Stage 5 / drift / re-install still work.
Also: `get_profile_install_dir(prefer_system_wide) → path` so the UI can preview the destination.
## `InstallOptions`
| Field | Default | Meaning |
|-------|---------|---------|
| `force_overwrite` | false | Treat as overwrite regardless of `collision_policy` |
| `prefer_system_wide` | false | user dir vs system dir |
| `register_with_os` | true | ColorSync / ICM / colord after copy |
| `collision_policy` | `"cancel"` | `overwrite` \| `rename` \| `cancel` |
| `open_color_panel` | false | open OS colour UI after success |
| `calibration_note` | null | echoed back in the result for the toast if applycal embedded a `.cal` |
## `InstallResult`
| Field | Meaning |
|-------|---------|
| `dest_path` | Final file written |
| `registered` | OS registration attempted and reported success |
| `overwritten` | Collision resolved by overwrite |
| `renamed` | Collision resolved by timestamp suffix |
| `opened_panel` | Colour UI launched |
| `message` | Human status for the banner |
| `calibration_note` | Pass-through |
## Destinations
| OS | User | System | On-disk extension |
|----|------|--------|-------------------|
| Windows | `%USERPROFILE%\AppData\Local\Microsoft\Windows\Color` | `%WINDIR%\System32\spool\drivers\color` | always `.icm` (`profile_extension_for_os`) |
| macOS | `~/Library/ColorSync/Profiles` | `/Library/ColorSync/Profiles` | always `.icc` |
| Linux | `~/.local/share/icc` | `/usr/share/color/icc` | always `.icc` |
The **source** may be `.icc` or `.icm`; the **destination filename** is `{stem}.{os_ext}`. Stem is rejected if it contains `..`, `/`, or `\`.
System-wide paths need elevation. `permission_message` must mention UAC / admin / sudo, not a generic I/O failure.
## Collision + atomic copy
`resolve_destination`:
- missing → write that name
- exists + overwrite / `force_overwrite` → same path, `overwritten=true`
- exists + rename → `{stem}-{unix_epoch}.{ext}` (`timestamped_filename`)
- exists + cancel → `Err("A profile named {filename} already exists at … Choose Overwrite, Rename, or Cancel.")`
Copy is atomic: write `{dest}.iccery-install.tmp` then `rename`. On rename failure, delete the tmp. Create parent dirs as needed.
## Source verification (`verify_source_profile`)
- Path is a file
- Extension `icc` or `icm` (case-insensitive)
- Size ≥ **128** bytes (ICC header)
## OS registration (`register_with_os=true`)
| OS | Mechanism |
|----|-----------|
| Windows | Copy into the Color folder is enough for ICM to pick it up; optional `InstallColorProfileW` when linked. Do not call `SetDeviceGammaRamp`. |
| macOS | File in ColorSync folder is sufficient. Optional open ColorSync Utility (`open -a "ColorSync Utility"`) when `open_color_panel`. |
| Linux | `colormgr import-profile` when `colormgr` exists on PATH; ignore if missing. Optional `open_color_panel` → GNOME Color / `colormgr` GUI if present. |
Never register a path that failed to copy.
## UI
`#btnInstallProfile` → collision dialog `#profileInstallCollisionDialog` (`profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`) matching the calibration collision pattern.
Settings: `default_install_location` `user`\|`system`, `ask_before_overwrite_profile`, `open_color_panel_after_install`.
+50
View File
@@ -0,0 +1,50 @@
# 20 — CGATS dataset interop (#94, #211)
Native Rust parser `src-tauri/src/cgats.rs` plus frontend `cgats_interop.js`. Used to jump the wizard to Stage 4/5 from a lab/spectral measurement file that did not originate in this ICCery run.
## Commands
| Command | Role |
|---------|------|
| `select_dataset_file` | **Open** dialog: `*.ti3, *.txt, *.cgats, *.csv` |
| `import_measurement_dataset` | Parse + write canonical `.ti3` into cwd, then `wizardState.setTarget` |
| `export_measurement_dataset` | Write canonical `.ti3` / txt |
| `inspect_dataset_preview` | Preview rows for the CGATS viewer (`DatasetSummary`) |
| `select_existing_target` | Related: open `.ti1`/`.ti2` for resume, not CGATS |
#211: import used a **save** dialog and left wizard state uninitialised. Always `pick_file` (open), then `wizardState.setTarget(basename, cwd)`. Never `save` for import.
v0.8.3 also opens an existing `.ti3` in the viewer without re-importing.
## `CgatsDataset` model
```
file_type: "CGATS.17" | "CTI3" | "ISO28178" | "CSV"
originator, created, descriptor, target_instrument
illuminant, observer, measurement_condition
color_rep: Rgb | Cmyk | Lab | DeviceN(n)
fields: [{ name, field_type }]
samples: [{ sample_id, sample_loc, device_coords[], lab[3], xyz[3], spectral[] }]
spectral_range: { start_nm, end_nm, step_nm, bands[] }
```
`FieldType`: `Id`, `DeviceCoord(i)`, `LabL/A/B`, `XyzX/Y/Z`, `Spectral(nm)`, `Custom(name)`.
`DatasetSummary` (preview): `patch_count`, `color_space`, `has_spectral`, `has_lab`, `spectral_start/end`, `illuminant`, `sample_preview` (first N rows).
## Normalisation (`to_canonical_ti3`)
Must satisfy the Argyll C CGATS parser (strict):
- Emit `CTI3` / `CGATS.17` keywords Argyll expects (`NUMBER_OF_FIELDS`, `BEGIN_DATA_FORMAT`, `BEGIN_DATA`, `COLOR_REP`, `DEVICE_CLASS`, `TARGET_INSTRUMENT` when known)
- Device coordinates 0255 → 0100 if the column max is > 100
- Field aliases: `SAMPLE_ID` / `SAMPLE_LOC` / `LAB_L` `LAB_A` `LAB_B` / `XYZ_*` / `SPEC_*` / `RGB_*` / `CMYK_*`
- Synthesise `COLOR_REP` and `DEVICE_CLASS` if missing
- Drop rows with no device **and** no Lab/XYZ/spectral
- Preserve spectral bands if present so `colprof -f` FWA still has data
Imported datasets can jump to Stage 4 (profile from the set) or Stage 5 (verify an existing profile against the set). Stage 12 stay locked unless sibling `.ti1`/`.ti2` exist.
## Security
Never `innerHTML` dataset names or originator strings (#114 was presets; same rule applies). Paths from the open dialog are the only allowed sources — no URL fetch of CGATS.
+82
View File
@@ -0,0 +1,82 @@
# 21 — UI reference
Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **242 element ids** in `src/index.html` — complete roster at the end.
## Shell
- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, Calibrate Printer, cal status chip, stepper 15.
- Main: notification banner, one visible `.stage`.
- Window 1280×800, min 1100×700, hidden until paint, dark `#1A1A22`.
Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`.
Banner: `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`. Auto-hides via `wizardState.noticeTimer`.
## Design tokens (`main.css`)
```
--bg-color: #1e1e1e
--panel-color: #252526
--text-color: #d4d4d4
--accent-color: #007acc
--border-color: #333333
--btn-height-sm/md/lg: 28 / 36 / 40 px
--btn-radius-sm/md: 4 / 6 px
```
Button classes: `.btn-sm` 28px, `.btn-md` 36px, `.btn-lg` 40px primary stage actions, `.btn-icon-sq` 36×36, `.icon-btn` 28×28 header, `.btn-properties` 36px. Action rows: `.stage-actions`, `.modal-actions`, `.chartread-actions`, `.print-actions-row`, `.btn-row`, `.btn-row-sm`, `.btn-row-end`, `.input-row-sm`. No ad-hoc inline heights (#177).
Help mode: `.has-tooltip` + `.tooltip-text`. Tooltips must overlay (fixed/absolute) so they do not expand document flow (#171).
Process logs: `<details class="log-container">` expandable (#54), wrapping text (#21). All logs share one CSS class — never per-stage IDs.
## Stage 0 ids
`stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`.
Collision: `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`.
## Stage 1 ids
`btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, radios `name="colourSpace"` (no element id on each radio), `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`.
## Stage 2 ids
`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`.
`#cupsOptionsGroup` is **hidden on Windows** (#48). `#chkPpdFallback` is Linux-only ColorModel=Gray + cm-calibration.
## Stage 3 ids
`stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`.
Button labels by state — see [15](15-stage3-chartread.md). XY stepper badges light in order Place → Align → Scan → Remove.
## Stage 4 ids
`colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`.
## Stage 5 ids
`btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`.
Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable container (`tabindex`) so it works without clicking the canvas.
## Modals
| Dialog | Root id | Controls |
|--------|---------|----------|
| Settings | `settingsDialog` | listed in [22](22-settings-presets.md) |
| About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` |
| Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` |
| Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` |
| Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel |
| Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` |
## Dialogs must go through host APIs
Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this.
## Complete `id=` roster (242)
`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`.
+69
View File
@@ -0,0 +1,69 @@
# 22 — Settings and presets
Persisted at `{app_data}/settings.json` via `load_settings` / `save_settings`. Invalid JSON loads `AppSettings::default()`. `save_settings` calls `validate()` then writes pretty JSON and **immediately** applies `log::set_max_level`.
## `AppSettings` (`settings.rs`)
| Field | Default | Notes |
|-------|---------|-------|
| `argyll_binary_dir` | `null` | Overrides bundled sidecars. `resolve_binary` checks this first. |
| `default_instrument` | `null` | **Stored but not applied to argv.** Stage 2 `#instrumentSelect` is the source of truth. Do not start honouring this without an explicit product decision. |
| `log_level` | `null` | `error` / `warn` / `info` / `debug` / `trace`. `null` → Debug in debug builds, Info in release. Applied at startup **and** on save (#158). |
| `delta_e_good_max` | `2.0` | Stage 3 swatch traffic-light "Good". Must be ≥ 0. |
| `delta_e_warning_max` | `5.0` | Stage 3 "Warning" band. Must be **strictly greater** than good. |
| `custom_presets` | `[]` | User presets; built-ins are **not** stored here. |
| `enable_i1pro2_leds` | `false` | Adds `chartread -Y l`. Default off so stock Argyll still runs. |
| `calibration_stale_days` | `30` | Stage 0 / Stage 1 reminder. |
| `default_install_location` | `"user"` | `"user"` \| `"system"` for Stage 5. |
| `ask_before_overwrite_profile` | `true` | |
| `open_color_panel_after_install` | `false` | |
Validation error strings (surface in `#deltaEThresholdError`):
- `"ΔE thresholds cannot be negative."`
- `"Good ΔE threshold must be strictly less than the warning threshold."`
Saving dispatches a DOM `settings-saved` event so the live swatch grid reclassifies without a re-read.
Settings dialog ids: `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`.
Log helpers: `get_log_path`, `get_recent_log_excerpt`, `open_log_dir`.
## `ProfilingPreset`
Every field is optional-defaulted with `#[serde(default)]` except the required identity / Stage 12 core. `applyPreset` **must** set TIFF DPI (#113) — a historical bug left `#tiffDpi` at 300 when loading the 150 dpi draft preset.
| Field | Type | Stage |
|-------|------|-------|
| `id`, `name`, `description` | string | identity. Names/descriptions go through `textContent`, never `innerHTML` (#114 XSS) |
| `colour_space` | `"rgb"` \| `"cmyk"` | 1 |
| `patch_count`, `white_patches`, `black_patches` | u32 | 1 |
| `grey_steps`, `single_channel_steps`, `neutral_steps` | Option\<u32\> | 1 advanced |
| `preconditioning_profile` | Option\<path\> | 1 `-c` |
| `neutral_concentration` | Option\<f64\> | 1 `-N` |
| `ofps_high_quality` | Option\<bool\> | 1 `-G` |
| `ofps_adaptation` | Option\<f64\> | 1 `-A` |
| `full_spread_algorithm` | Option\<string\> | 1 `-t/-r/-R/-q/-Q/-i/-I` |
| `total_ink_limit` | Option\<u32\> | 1 `-l` CMYK |
| `dark_emphasis`, `device_power` | Option\<f64\> | 1 `-V` / `-p` |
| `instrument`, `page_size`, `bit_depth`, `dpi` | | 2 |
| `random_seed`, `no_randomize` | | 2 `#printtargLayoutOrder` |
| `calibration_file`, `apply_calibration` | | 0 / 2 `-K` |
| `colprof_algorithm`, `colprof_quality`, `colprof_intent` | | 4 |
| `colprof_fwa`, `colprof_illuminant`, `colprof_observer` | | 4 |
| `colprof_input_viewing_cond`, `colprof_output_viewing_cond` | | 4 |
## Built-in presets (`get_default_presets`)
Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JSON via `export_preset_json` / `import_preset_json` with schema validation (unknown keys ignored via serde default; missing required fields fail).
| id | Name | Space | Patches | Page | Bit | DPI | Quality | Extra |
|----|------|-------|---------|------|-----|-----|---------|-------|
| `preset-std-rgb` | Standard RGB Photo (800 patches) | rgb | 800, white 4 | A4 | 8 | 300 | `m` | FWA D50, seed 1, instrument i1, alg `l` |
| `preset-hq-cmyk` | High-Gamut CMYK Proofing (1500 patches) | cmyk | 1500, black 8 | A3 | 16 | 300 | `h` | ink limit 320, FWA D50, seed 1 |
| `preset-draft-rgb` | Fast RGB Draft (400 patches) | rgb | 400 | A4 | 8 | **150** | `l` | FWA D50, seed 1 |
| `preset-ultra-rgb` | Ultra Precision RGB (2500 patches) | rgb | 2500, white 6, black 6 | A3 | 16 | 300 | `u` | `ofps_high_quality=true` (`-G`), FWA D50, seed 1 |
All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`.
UI: `#presetSelect`, `#btnSavePresetModal``#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog``#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`).
+47
View File
@@ -0,0 +1,47 @@
# 23 — Graphical assets
Reuse these in the rewrite. Do not replace the cone.
## Wordmark — `src/assets/ICCery-logo.svg`
ViewBox `0 0 480 160`.
- Waffle cone path `M 50 82 L 110 82 L 80 142 Z`, fill `#FAD7A1`, stroke `#E59866`, clipped waffle grid.
- Scoops: C `#00BCEB` at (63,72) r=22; M `#EC008C` at (97,72); Y `#FFED00` at (80,48); white stroke 2.5; highlight dots.
- K cherry: stem + circle `#1E293B` at (80,24) r=7.
- Wordmark: `ICC` white, `ery` linear gradient `#00AEEF``#0066CC`, weight 800, size 58, x=140 y=105.
Header usage: height `4.42rem`, max-width 175px, object-position left.
## App icon — `src/assets/app-icon.svg`
Cone-only mark for window/taskbar. Raster set:
- `src-tauri/icons/32x32.png`, `64x64.png`, `128x128.png`, `128x128@2x.png`
- `icon.icns`, `icon.ico`, `icon.png`
- Store / Android / iOS variants under `icons/` (legacy Tauri generator output; rewrite may subset)
## Installer chrome
| File | Use |
|------|-----|
| `icons/dmg-background.png` (+ `@2x`, `.svg`) | macOS DMG window (ice cream / wordmark scene). Headless `dmgbuild` after #189 |
| `icons/wix-banner.bmp`, `wix-dialog.bmp` | MSI |
| `icons/nsis-header.bmp`, `nsis-sidebar.bmp` | NSIS |
## Gamut reference
- **Ship `src/assets/sRGB.gam`** — real Argyll sRGB gamut used by the viewer.
- `src-tauri/argyll/reference_gamuts/sRGB.gam` is an **8-cusp stub**. Do not use it as the overlay (#185 notes / gamut rewrite notes).
## Third-party JS (legacy)
- `three.min.js` r128 via script tag
- `OrbitControls.js`, `CSS2DRenderer.js` IIFE attaching to `THREE`
- `vendor/quickhull.js` — used when `.gam` has vertices but no faces
A rewrite may use any WebGL engine; keep Lab mapping `X=a* Y=L* Z=b*` and CSS2D axis labels.
## NSIS USB extras
`windows/hooks.nsh` offers Argyll USB instrument driver install when elevated, and maps missing user-shell-folder drive letters in the elevated session (network home drives) to avoid "Invalid Drive". Preserve the driver-offer behaviour; the DOS-device mapping is a Windows installer idiosyncrasy.
+548
View File
@@ -0,0 +1,548 @@
# 24 — Issue tickets and rewrite invariants
> Line numbers refer to ICCery v0.8.5 (`/tmp/ICCery` at analysis time) and the Gronod ArgyllCMS 3.5.0 fork.
Source corpus: Gitea issues `#1``#225` (`/tmp/iccery-research/issues/`), `issues-index.md`, `ROADMAP.md`, `git log` on `/tmp/ICCery` (main @ `e12ef41`, tag `v0.8.5`), PR titles pages 12, plus sibling issue dumps `argyll-issues.json` and `cpu-issues.json`.
This is a rewrite-invariants document. Every closed bug gets a **root cause → fix → invariant**. Features are traced so a rewrite does not re-invent the same CLI/IPC contracts.
Open tickets `#223` and `#224` are **implemented on main** (see § Open issues). Won't-fix `#90` / `#96` are documented in § Won't-fix.
---
## 1. Feature genealogy (issue → ship)
The product is a **printer-profiling workstation**: vanilla JS + Tauri v2 wrapping ArgyllCMS sidecars over stdin/stdout JSON (`-u`) with a disk-artefact wizard.
| Era | Version | Issues | What landed |
|---|---|---|---|
| Scaffold | `v0.1.0` | `#1`, `#2` | Tauri v2 shell, AGPLv3 isolation, `ProcessManager` with `process:stdout/stderr/exit` events |
| Stage 12 | `v0.1.3` | `#3`, `#4` | `targen` patch sets; `printtarg` layout + instrument geometry |
| Stage 3 | `v0.1.6` | `#5`, `#6` | `chartread -u` state machine + live swatch grid + ΔE₀₀ |
| Stage 45 | `v0.1.9` | `#7`, `#8` | `colprof` cLUT; `profcheck` peak/avg/RMS |
| Print + UI | `v0.1.10``0.1.13` | `#21`, `#23`, `#25``#28`, `#34`, `#54`, `#61` | Logo; raw GDI/CUPS print; DEVMODE prefs; collapsible logs; 1280×800 window |
| Gamut + ship | `v0.1.14``0.2.0` | `#9`, `#10`, `#57`, `#58`, `#60`, `#69` | `iccgamut` + Three.js; settings/packaging; artefact-gated wizard |
| Hotfix | `v0.2.1` | `#84` (from `#63` regression) | Stdin decoupled from `Child::wait()` |
| M8 | `v0.3.0` | `#86`, `#87`, `#88`, `#89` | `instlist`; multi-pass `average`; presets; 3D hull |
| Hotfixes | `v0.3.1``0.3.6` | `#103`, `#108``#116`, `#119`, `#127`, `#134`, `#137` | Browse dialogs, GLIBC, averaging snapshots, comm-port vs index, density vs dir, XSS/DPI, fetch-argyll, `ARGYLL_NOT_INTERACTIVE`, Accept/Override |
| macOS print | `v0.4.0` | `#91`, `#92` | Universal dmg path + CUPS raw spooler |
| Production | `v0.5.0``0.5.5` | `#139``#141`, `#147`/`#149`, `#151`, `#158``#165` | Logging, Stage 3 resume from `.ti2`, advanced `targen`, `kill_all` on exit, deterministic `-R 1`, universal sidecars |
| Enterprise colour | `v0.6.0``0.7.4` | `#94`, `#171`, `#172`, `#175``#179`, `#184`, `#185`, `#188`, `#189` | CGATS import, overlay tooltips, profile picker, `d`/`u` chartread keys, OBA/FWA, button system, swatch split, `.gam` dual-table parse, ΔE thresholds, ColorSync/vendor bypass, DMG background |
| Hardware + analytics | `v0.8.1``0.8.2` | `#204`, `#93`, `#95` | i1Pro2 `-Y l`; XY tables; drift history |
| Reliability | `v0.8.3``0.8.4` | `#210``#215`, `#225` | Native file pickers, Node-safe gamut tests, atomic history, frontend CI, Monterey WKWebView survival |
| Cal + install | `v0.8.5` | `#224`, `#223` (tickets still *open*) | Stage 0 `printcal`/`applycal`; OS profile install |
ArgyllCMS fork genealogy (why ICCery's CLI looks the way it does):
| Argyll issue | Flag / contract ICCery depends on |
|---|---|
| argyll `#1` | `chartread -u``ROW_COLORS_JSON:` per row |
| argyll `#2` | `targen -u` JSON progress |
| argyll `#3` | `printtarg -u` page manifest JSON |
| argyll `#4` | `colprof -u` JSON progress |
| argyll `#5` | `profcheck -u` JSON ΔE report |
| argyll `#6` | `instlist` JSON `{event:"instruments", devices:[{port,name,type}]}` |
| argyll `#19` | `printtarg -d "<label>"` custom chart legend (ICCery `#119`) |
| argyll `#24` | Windows anonymous-pipe `PeekNamedPipe` so `chartread` does not deadlock on `ReadFile` |
| argyll `#32` | ad-hoc `codesign -s -` of Mach-O sidecars (ICCery `#165`) |
| argyll `#37` | i1Pro2 LED — ICCery shipped as `chartread -Y l` (`#204`), **not** the originally proposed `-L` |
---
## 2. Closed bugs — root cause → fix → rewrite invariant
Grouped by subsystem. Issue numbers are always cited.
### 2.1 Process manager / IPC
**`#21` Process logs spill horizontally**
- **Root cause:** `#colprofLog` / `#profcheckLog` had no CSS; even stages with `white-space: pre-wrap` could not wrap Argyll's space-less progress dots.
- **Fix:** Class selector `.log-container pre { white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 200px; }` (PR `#22`). Later `#54` made boxes collapsible.
- **Invariant:** All subprocess consoles share one CSS class. Never style per-stage IDs. Argyll stdout is not English prose; it is long runs of `.` and must wrap *anywhere*.
**`#46` Visible cmd.exe on every Argyll spawn (Windows)**
- **Root cause:** Sidecars are `IMAGE_SUBSYSTEM_WINDOWS_CUI`. `tokio::process::Command` without creation flags allocates a console.
- **Fix:** `command.creation_flags(0x08000000)` (`CREATE_NO_WINDOW`) in `ProcessManager::spawn` (`#[cfg(windows)]`).
- **Invariant:** Every Windows spawn of an Argyll CUI binary **must** set `CREATE_NO_WINDOW`. Do not "fix" this by rebuilding Argyll as WINDOWS_GUI — that would also kill stdin/stdout.
**`#63` Zombie children (enhancement that caused `#84`)**
- **Root cause:** 100 ms `try_wait` polling never removed finished `Child` handles.
- **Fix (PR `#77`):** `child.wait().await` and reap from the map.
- **Invariant:** Reap is required. But **never** `wait()` while still holding the `Mutex<Child>` that `send_stdin` needs — see `#84`.
**`#84` P0: `Child::wait()` deadlocks `send_stdin` and closes stdin (v0.2.1)**
- **Root cause:** PR `#77` awaited `c.wait()` under `Arc<Mutex<Child>>`. Tokio's `Child::wait` **drops stdin** to avoid parent/child deadlock. Interactive `chartread` therefore (1) could not acquire the mutex for Calibrate/Retry/Skip/Cancel, and (2) had its pipe closed immediately after spawn.
- **Fix (PR `#83`, v0.2.1):** `take()` stdin/stdout/stderr at spawn; store `ChildStdin` in a separate `stdins: HashMap<String, Arc<Mutex<ChildStdin>>>`; `tokio::select!` between `child.wait()` and a oneshot `kill_rx`.
- **Invariant:** Interactive subprocess lifetime has **three** independent handles: wait task, stdin mutex, kill oneshot. `send_stdin` must never take the `Child` lock. Never call `wait()` before `take()`-ing stdin.
**`#85` `resolve_binary` ignores `.exe`**
- **Root cause:** Path joins used `"targen"`; on disk the file is `targen.exe`. `exists()` returned false for custom dirs and bundled resources.
- **Fix (PR `#97`):** On Windows, try `{name}` and `{name}.exe` for both settings dir and resource resolution. Unit-test both.
- **Invariant:** Windows binary lookup is always a two-candidate search. Do not assume Unix names in `tauri.conf.json` resources.
**`#116` Duplicate spawn ids steal the live child's maps**
- **Root cause:** `stdins.insert(id)` / `killers.insert(id)` with no occupancy check. Averaging reuses `chartread_${basename}`. Old wait-task `remove()`s the **new** child's entries → "Process not found" on Calibrate.
- **Fix (PR `#123`):** Hard-error if `id` is occupied; disable Start / Measure Another Sheet until `process:exit`. Tokio test: spawn, reject duplicate, stdin still works, kill, reuse.
- **Invariant:** Process IDs are exclusive leases. Prefer a loud error over silently replacing the occupant. After exit, the id is reusable.
**`#134` Calibrate button does nothing; `instlist` shows nothing**
- **Root cause:** (1) Without `ARGYLL_NOT_INTERACTIVE=1`, Argyll on Windows uses `_getch()` / `ReadConsoleInput()`, ignoring piped stdin — especially combined with `#46`'s hidden console. (2) Forked `instlist` emits JSON; frontend only had a text regex.
- **Fix (PR `#135`, v0.3.5):** `command.env("ARGYLL_NOT_INTERACTIVE", "1")` on every spawn; parse `event === "instruments"` JSON with text fallback.
- **Invariant:** All Argyll children get `ARGYLL_NOT_INTERACTIVE=1`. Frontend instrument lists parse JSON first. Related upstream: argyll `#24` (`PeekNamedPipe` before `ReadFile` on anonymous pipes) — the env var is necessary but not sufficient on Windows.
**`#147` / `#149` Orphan `chartread` after window close**
- **Root cause:** Only Stage 3 Cancel called `kill_process`. `lib.rs` used bare `.run(...)` with no `RunEvent::Exit` / `CloseRequested`. Spectrophotometer stays USB-locked.
- **Fix:** `ProcessManager::kill_all` (drop stdins, signal killers, `start_kill`); hook app/window exit.
- **Invariant:** Host exit **always** drains the process table. Interactive instruments are the scary case, but `colprof`/`printtarg` must die too.
**`#158` Settings log level is decorative**
- **Root cause:** `tauri-plugin-log` filter hardcoded `Debug` vs `Info` from `cfg!(debug_assertions)`. `AppSettings.log_level` was persisted and ignored.
- **Fix:** Map stored string → `LevelFilter` at startup and on Save.
- **Invariant:** A settings control that does not change observable behaviour is a bug. Log level is runtime state, not a compile-time constant.
---
### 2.2 Print (Win / Mac / Linux)
**`#33` Windows CI: unresolved GDI imports**
- **Root cause:** `windows` crate 0.58 does not export `SetICMMode` / `ICM_OFF` under `Win32::Graphics::Gdi` as used in `print/windows.rs`. GitHub `windows-2025` runner compiled the print backend and died with `error[E0432]`.
- **Fix:** Declare the GDI printing functions/structs used (`CreateDCW`, ICM off, `StretchDIBits`, …) directly rather than depending on incomplete crate bindings (`a9802ad`, `c9e16ab`). Later also: runner label, native rustup, `shell: powershell`.
- **Invariant:** Colour-management bypass (`SetICMMode(ICM_OFF)`) is a **link-time** contract. Pin/vend the FFI; do not assume `windows` crate surface area. CI on the real Windows target is the only proof.
**`#36` DEVMODE preferences not applied**
- **Root cause:** OEM dialog (`DocumentPropertiesW`) writes public `DEVMODEW` **plus** `dmDriverExtra` private bytes. The print path was not forwarding the full buffer (and mishandled the anonymous union for `dmDefaultSource` / `dmOrientation`) into `CreateDCW`/`StartDocW`. Epson "Print Preview" etc. therefore vanished.
- **Fix:** Retain the entire DEVMODE allocation including `dmDriverExtra`; fix union field access (`19449e3`).
- **Invariant:** Windows driver settings are a **blob**, not a parsed struct. Never memcpy only `dmSize` bytes. Never reconstruct DEVMODE from a handful of public fields.
**`#48` CUPS/PPD "Uncorrected Passthrough" shown on Windows**
- **Root cause:** `#cupsOptionsGroup` rendered unconditionally.
- **Fix:** Hide on `navigator.userAgent` Windows (v0.1.7).
- **Invariant:** Print UI is per-backend. Windows = DEVMODE; Unix = PPD/CUPS. Do not show controls the active spooler cannot honour.
**`#67` Duplicate print IPC (cleanup, not a user bug)**
- **Fix:** Collapse `get_windows_printers`/`get_cups_printers`/`print_target_*` into `get_printers` / `print_target`.
- **Invariant:** One IPC surface, platform switch inside Rust.
**`#188` macOS: Preferences opens System Settings; no way to disable driver CM (Epson XP-55, Canon Pro 9500 II)**
- **Root cause:** "Preferences" deep-linked to the OS Printers & Scanners pane, not the selected queue's driver panel. ColorSync plus vendor filters (`CNIJIntent2`, Epson `ColorCorrection`) re-managed the raw target. Follow-on: `PMSessionSetColorMatchingMode` 3-arg SPI **crashed**.
- **Fix (v0.7.20.7.3):** Native `NSPrintPanel` bound to the current `PMPrinter`; PPD/`lpoptions` media-type extraction; vendor uncorrected-color keys; `AP_ColorMatchingMode=AP_ApplicationColorMatching`; **remove** the crashing 3-arg SPI and use the validated 2-arg / lock sequence (`8b01c98`). Windows media_types added in the same stack so CI still built.
- **Invariant:** Raw print means **three** layers must all be off: OS ColorSync, driver colour, and ICM/CUPS filters. Opening the generic Printers pane is not a preferences dialog. Never call private Core Printing SPIs whose arity is guessed.
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
---
### 2.3 Stage 1 — targen
**`#44` Patch count ignored → always 836**
- **Root cause:** Frontend sent `patch_count`; Rust `build_targen_args` only read `total_patches`. `-f` omitted → Argyll's built-in default **836** RGB patches.
- **Fix (v0.1.5, PR `#45`):** Honour `patch_count` (fallback `total_patches`); unit-test argv.
- **Invariant:** `targen` without `-f` is not "use the UI value". Default 836 is an Argyll formula, not an ICCery default. Serde field names must match across JS and Rust **and** be tested.
**`#59` Empty working directory**
- **Root cause:** Basename typed without Browse → `cwd: ""` → process launch dir (read-only / undefined). Same hole in later stages.
- **Fix (v0.1.12):** Init `currentWorkingDir` to `document_dir`/`home_dir`; disable Generate until both basename and cwd exist; backend also refuses empty cwd.
- **Invariant:** Empty cwd is illegal. Frontend guard **and** backend fallback. Never spawn Argyll in the install/resource directory.
**`#103` Stage 1 Browse does nothing; Generate stays disabled**
- **Root cause:** Frontend called `window.__TAURI__.dialog` (Tauri v1 API). With `withGlobalTauri: true` on Tauri v2, `window.__TAURI__.dialog` is **undefined**. Same class of bug as `#210`/`#211`.
- **Fix (v0.3.1, PR `#104`):** Backend command `select_target_file` using `app.dialog()`.
- **Invariant:** **Never** use `window.__TAURI__.dialog`. All file pickers are Rust commands with explicit filters. See also `#172`, `#210`, `#211`.
**`#162` Advanced options misaligned**
- **Root cause:** Mixed control heights (number vs range vs checkbox-without-label) plus a full-width `-c` row breaking the 2-col grid; flex-end hacks.
- **Fix (v0.5.5 / v0.6.1):** Semantic sub-groups, normalized row heights, flexbox auto-margins.
- **Invariant:** Advanced `targen` controls are a 2-column grid of equal-height rows. Checkboxes get a label column. Do not use `justify-content: flex-end` to fake alignment.
**`#172` Preconditioning browse filters `.ti1/.ti2/.ti3` instead of `.icc/.icm/.mpp`**
- **Root cause:** Reused `select_target_file` (target artefacts) for `targen -c`.
- **Fix:** Dedicated `select_profile_file`.
- **Invariant:** Each browse button has its **own** backend command and extension filter. Sharing "the dialog command" is how you open a Save-`.ti1` dialog for an ICC.
**`#211` CGATS Import opens a Save `.ti1` dialog and jumps to Stage 4 with empty basename**
- **Root cause:** `handleImport` invoked `select_target_file` (`save_file`, filter `ti1`). Empty `cwd`/`basename` were forwarded into `import_measurement_dataset`; `wizardState.setTarget` never ran.
- **Fix (v0.8.3):** `select_dataset_file` (open, filters `ti3/txt/cgats/csv`); sync wizard target dir+basename from the imported artefact.
- **Invariant:** Import = **open** dialog. After import, `basename`/`cwd` are initialized from the dataset before any stage jump. Do not reuse the target-save picker.
---
### 2.4 Stage 2 — printtarg
**`#50` Redundant Stage 2 badges (cleanup)**
- **Fix:** Remove duplicate status text. Not a functional invariant beyond "one source of layout truth".
**`#58` / `#70` TIFF previews fail in WebView; `image` crate Windows-only**
- **Root cause:** Browsers/WKWebView/WebView2 do not decode TIFF. `img.src = base64(tiff)` is a blank. `image` was under `[target.'cfg(windows)'.dependencies]` (GDI print), so Linux/macOS could not even compile a converter.
- **Fix:** Move `image` (png+tiff features) to common deps; `read_tiff_preview_png` decodes, caps ~1200 px, returns PNG base64. Keep `read_file_base64` for **text** artefacts (`.gam`).
- **Invariant:** Never feed TIFF to `<img>`. Preview pipeline is always TIFF→PNG in Rust. The `image` crate is a **common** dependency, not Windows-only.
**`#68` printtarg JSON parsed by hunting for a brace**
- **Root cause:** String-index search for `{\n "event": "manifest"` instead of a structured object.
- **Fix:** Parse a single JSON event (Argyll `#3` `-u` manifest).
- **Invariant:** `-u` tools emit one JSON object per event. Accumulate stdout, `JSON.parse` whole objects; do not `indexOf` pretty-printed fragments.
**`#163` Regenerating Stage 2 shuffles patches (printed sheet ≠ `.ti2`)**
- **Root cause:** `printtarg` randomizes layout unless `-R <seed>` or `-r`. `build_printtarg_args` passed neither.
- **Fix (v0.5.5):** Default `-R 1`; optional custom seed and raster-order `-r`.
- **Invariant:** Printed charts are not reproducible without an explicit seed. Default is **deterministic**. Re-running Stage 2 on the same `.ti1` must produce the same `.ti2`/`.tif` or `chartread` will score the wrong patches.
**`#119` (feature, not bug) `printtarg -d` is a *label*, not a directory**
- See idiosyncrasies. Do not confuse with `iccgamut -d` (`#112`).
---
### 2.5 Stage 3 — chartread / averaging / XY
**`#109` Multi-pass averaging overwrites the same `.ti3`**
- **Root cause:** `#87` UI shipped, but every `chartread` wrote `{basename}.ti3`. Pass 2 clobbered pass 1. `average` was invoked as `foo.ti3 foo.ti3 foo.ti3`.
- **Fix:** After each successful exit, snapshot `{basename}.ti3``{basename}_pass{N}.ti3`. Canonical `{basename}.ti3` is written only at Finish (copy of pass1, or `average -v pass1 pass2 … basename.ti3`). Do **not** change the `chartread` inoutfile (still must match `.ti2` basename).
- **Invariant:** Measurement identity ≠ averaging identity. `chartread` always uses the chart basename. Pass files are snapshots. Stage 4 never sees a pass file unless Finish has run.
**`#110` Stage 4 unlocks on first pass, before Average**
- **Root cause:** `process:exit` of pass 1 called `wizardState.setStage3Result`. `verify_stage_artefacts` saw `{basename}.ti3` and enabled Stage 4 while "Measure Another Sheet" was still offered.
- **Fix:** Same PR as `#109`. `setStage3Result` only from **Finish and Average** / Accept-single-pass. Prefer the canonical-file rule over a new `stage3_accepted` flag.
- **Invariant:** Wizard gates on **disk artefacts with defined meaning**. `{basename}.ti3` means "accepted measurement", not "a pass happened".
**`#111` `instlist` ordinal passed as `chartread -c`**
- **Root cause:** Dropdown `value = inst.index` (1-based instrument list). `build_chartread_args` emitted `-c {index}`. Argyll `-c` is a **communication port** list (`chartread -??`), a different enumeration. Works by coincidence for a single USB i1Pro on port 1.
- **Fix:** Do not assume ordinal equality. Use `instlist` as presence/health; pass `-c` only when the value is a documented comm-port. Tighten regex to require instrument tokens.
- **Invariant:** `instlist` index ≠ `-c` port. After JSON `instlist` (argyll `#6` / ICCery `#134`), the field to pass is `device.port`, never the array index, and only if it is a real comm port.
**`#137` (feature) Accept/Override + multi-sheet prompts**
- Chartread emits multi-key warnings. UI must send the exact key (`n`/`y`/…) the prompt asks for, not just Enter.
**`#175` Stage 3 cannot complete — never sends `d`**
- **Root cause:** After the last strip, `chartread` waits for `d` (Done) before writing `.ti3`. ICCery never sent it. Follow-on: `snapshot_ti3` IPC used `passIndex` vs the deserializer's expected key.
- **Fix (v0.6.70.6.8):** Explicit **Done & Save .ti3**`d\n`; **Undo Strip**`u\n`; completion state machine; `invoke("snapshot_ti3", { passIndex })` camelCase to match Tauri v2 serde rename.
- **Invariant:** `chartread` is not finished when the last row JSON arrives. Done is a **keystroke**. Tauri v2 IPC argument names are camelCase on the JS side. Always add a completion control rather than inferring EOF.
**`#93` XY tables (SpectroScan / i1iO)** — feature, but its invariants are bug-shaped: classify **multi-line** prompts; fiducial alignment is a 4-step checklist; cancel must **park the head**.
**`#204` i1Pro2 LEDs** — pass `-Y l` (not `-L`). Silent no-op on instruments without LEDs.
---
### 2.6 Stage 4 — colprof
**`#56` Stage 4 is broken (IPC schema / process-id mismatch)**
- **Root cause:** `colprof.js` sent `{ basename, cwd }`. Rust expected `{ ti3_path, icc_path }`. Process IDs disagreed (`colprof_${basename}` vs `colprof_{icc_path}`), so UI listeners never fired even on a successful spawn.
- **Fix (PR `#64`, v0.1.11):** Align struct, argv builder, and process id with the frontend.
- **Invariant:** Spawn id is a **protocol**. Frontend listen-filters and backend spawn ids are the same string. Serde structs are tested against the JS payload, not against a parallel imagined schema.
**`#69` Profile extension `.icm` (Windows) vs `.icc` (Unix)**
- **Root cause:** Hardcoded `${basename}.icc`. Argyll `colprof` writes `.icm` on Windows. `profcheck` / `iccgamut` then "cannot find profile".
- **Fix:** Platform-aware resolver, or existence-check both extensions. Used by Stage 5 and gamut (`#57`).
- **Invariant:** Never hardcode `.icc`. Resolve `{icc, icm}` on disk. This also applies to install (`#223`) and preconditioning (`#172`).
**`#176` OBA/FWA + viewing conditions** — feature. `colprof -c` / `-d` here are **illuminant / viewing condition**, not directories and not `printtarg -d` labels.
**`#210` Custom `.sp` Browse: `window.__TAURI__.dialog` TypeError**
- **Root cause:** Identical to `#103`. No `select_spectrum_file` command.
- **Fix (v0.8.3):** Native `select_spectrum_file` (filter `.sp`).
- **Invariant:** Same as `#103`. Third time this class of bug shipped (`#103`, `#210`, `#211`). A rewrite should have a single `pick_file { filters }` command and never touch `window.__TAURI__.dialog`.
---
### 2.7 Stage 5 — gamut / profcheck / history
**`#57` 3D gamut is a dead panel**
- **Root cause:** `extract_gamut` / `loadGamutMesh` existed but nothing called them after `colprof`. `initGamutViewer` never stored meshes. Blocked by `#56` and `#69`.
- **Fix (v0.1.14):** Run `iccgamut` after successful profile; load printer `.gam` + bundled `sRGB.gam`; dispose previous geometries; `ResizeObserver` on the hidden Stage 5 canvas.
- **Invariant:** Gamut is a **post-colprof artefact**, not a demo mesh. Viewer must not allocate WebGL until the stage is shown (`#225`).
**`#112` `iccgamut -d 50.0` is surface density, not a directory**
- **Root cause:** `extract_gamut` passed `-d 50.0`. In Argyll, `iccgamut -d s` is **surface point density** (useful ~110). Density 50 yields thousands of vertices and freezes the O(n²) hull filter. Cwd is already set on the spawn.
- **Fix (PR `#121`):** `-d 10`; extract `build_iccgamut_args`; unit-test argv. Do not pass `-w` (VRML) unless required.
- **Invariant:** `-d` is **not** "directory" on `iccgamut`. Cwd is a ProcessManager concern. Density default is 10. Argv builders exist so this cannot silently regress.
**`#179` Mesh empty; profcheck cards show 0.00**
- **Root cause:** `.gam` has **two** CGATS tables (vertices `VERTEX_NO LAB_L LAB_A LAB_B`, then faces `VERTEX_0 VERTEX_1 VERTEX_2`). Parser kept only the first `BEGIN_DATA` and produced `{L,a,b}`. QuickHull indexed `p.x/p.y/p.z` → all undefined → empty hull. Profcheck regex expected one wording of `errors(CIEDE2000): max. =`; real output variants yielded 0.
- **Fix (v0.7.0):** Dual-table parse; map `x=a*, y=L*, z=b*`; use file faces when present; broader max/avg/rms regexes + user-visible parse warnings.
- **Invariant:** `.gam` is two tables. Lab is not XYZ-style `{x,y,z}` until remapped. Profcheck text is not a stable API — prefer `profcheck -u` JSON (argyll `#5`) and keep regex as fallback only.
**`#117` Inlined QuickHull** — modularize under `src/js/vendor/quickhull.js` (and later prefer file faces over recomputing hulls).
**`#185` Gamut rework** — CIELAB axes + CSS2D labels, `EdgesGeometry` sRGB reference, per-vertex `labToSrgb()`, layer toggles. Invariant: reference overlay is edges, not a competing solid that hides the profile.
**`#212` `gamut_viewer.test.js` : `window is not defined`**
- **Root cause:** Module top-level `const { invoke } = window.__TAURI__.core`. Node test import evaluates that before any polyfill. Unlike `profcheck.test.js` / `chartread.test.js`, no `globalThis.window` mock and no CLI auto-run.
- **Fix:** Guard `typeof window !== 'undefined'`; polyfill in the test; `node src/js/gamut_viewer.test.js` must exit non-zero on failure. Later `#215` runs this in CI.
- **Invariant:** Frontend modules that `import` in Node tests cannot touch `window` at load time. Tests are first-class; if AGENTS.md documents `node src/js/….test.js`, CI must run it (`#215`).
**`#213` `verification_history.json` non-atomic write**
- **Root cause:** `fs::write` in place. Crash ⇒ truncated JSON. `load_history_file` treats parse failure as **empty vec**, so the next save wipes the archive. Contradicted AGENTS.md's own atomic-write rule.
- **Fix (v0.8.4):** Write `.tmp`, flush/sync, `rename`.
- **Invariant:** History files are append-only user data. Write temp+rename. Parse failure must **not** be indistinguishable from "no history".
**`#95` Drift analytics** — 1,000-record cap, SVG dual-series, consecutive-breach card, RFC-4180 CSV. Relies on `#213`.
---
### 2.8 macOS window / WebKit
**`#61` Default window 800×600**
- **Fix:** 1280×800 default, min 1100×700.
- **Invariant:** The wizard + TIFF cards + WebGL viewer are not a mobile layout. Set min bounds in `tauri.conf.json`.
**`#148` Align `minimumSystemVersion` with Argyll**
- Argyll macOS builds and Tauri 2 / WebGL do not support 10.15 in practice.
**`#165` Unsigned Argyll sidecars `SIGKILL` on Apple Silicon**
- **Root cause:** arm64 requires at least ad-hoc signature. `fetch-argyll.mjs` staged Jam-built unsigned Mach-Os into Resources. Kernel kills with `Killed: 9`, no useful error.
- **Fix:** Upstream argyll `#32` (`v3.5.0-ICCery.1.5`) `codesign -f -s -` in `makepackagebin.sh` + CI `codesign -dvv`, including `lipo` fat binaries.
- **Invariant:** Every shipped Mach-O sidecar is signed (ad-hoc at minimum). Fetch pipeline must verify signatures, not assume "it ran on Intel".
**`#164` Universal binary (Intel + arm64)**
- Need universal Argyll archive (argyll `#27`), runtime fallback if a slice is missing, CI `--no-run` when cross-compiling tests for the other arch.
**`#225` Monterey white-flash loop then silent exit (v0.8.4)**
- **Root cause:** Main window `visible: true` with no dark backing. `initGamutViewer()` created `THREE.WebGLRenderer({ antialias: true, alpha: true })` and started rAF at `DOMContentLoaded` **while Stage 5 was hidden**. On macOS 12 (esp. Intel GPU) the Web Content/GPU helper died; WKWebView respawned its **white** default store; after a few cycles the host tore the window down. No Crash Reporter on `ICCery.app`. `minimumSystemVersion` still claimed 10.15.
- **Fix:** Defer WebGL until the gamut viewer is shown; survive context-lost; hide window until first paint; dark WKWebView `backgroundColor`; log unexpected window destroy / Web Content death; raise `minimumSystemVersion` to **12.0**.
- **Invariant:** Do not create a WebGL context at startup. Do not show a window before first paint on macOS. Helper-process death ≠ app crash — log it. Support matrix must match WKWebView/WebGL reality.
---
### 2.9 Packaging / CI
**`#38` Unused / duplicated crates** — audit deps; don't let Windows-only crates leak (inverse of `#70`).
**`#40` Linux CI too slow**
- **Fix (PR `#41`):** Allow strip (`NO_STRIP` off), rust-cache `workspaces: "src-tauri -> target"`, apt archive cache, `--bundles deb,appimage` (skip rpm), upload-artifact v4.
**`#42` Windows MSI/NSIS branding.**
**`#52` Strip foreign-platform Argyll binaries from each OS bundle.**
- **Invariant:** A Windows installer must not ship `linux-x86_64/` and `macOS/` trees. Fetch-at-build (`#127`) makes this a packaging filter, not a git-lfs problem.
**`#80` Mermaid diagram does not render**
- **Root cause:** Node label `Raw Print Subsystem (GDI / CUPS)` — parentheses parsed as shape syntax.
- **Fix:** Quote / drop parens.
- **Invariant:** Mermaid node text with `()` must be quoted.
**`#91` Universal `.dmg` + codesign pipeline (still evolving through `#189`).**
**`#108` `.deb` requires GLIBC_2.39 (won't run on Ubuntu 22.04)**
- **Root cause:** Linux CI runner newer than the support target. Binary linked against glibc 2.39; 22.04 ships 2.35.
- **Fix (v0.3.2):** Build on **Ubuntu 22.04 LTS**.
- **Invariant:** The CI image **is** the minimum glibc. If you support 22.04, you build on 22.04 (or older), not `ubuntu-latest`.
**`#127` Fetch Argyll at build time + Windows USB driver offer**
- Removed vendored binaries. NSIS hook for `ArgyllCMS_install_USB.exe`. Follow-on Windows NSIS bugs in the same train: LogicLib admin check, domain-user install path must be a local fixed disk, shell-folder drive mapping, skip WebView2 bootstrapper `msiexec` invalid-drive.
- **Invariant:** Sidecars are release artefacts of `gronod/argyllcms`, not git blobs. Windows USB instruments need the libusb0 INF. Packaging scripts must `tr -d '\r'` manifest lists (argyll `#21` — CRLF in `binfiles` dropped drivers/docs from the zip).
**`#189` DMG has no background image**
- AppleScript Finder decoration does not run headlessly. Multiple CI attempts (`#196`, `#208`, `#219`) then **dmgbuild** (`#222`, commit `6976ced`).
- **Invariant:** Do not use Finder AppleScript to style a DMG on a headless runner. Use `dmgbuild`.
**`#215` Frontend tests not in CI**
- Added `npm test` (profcheck, chartread, gamut_viewer) to macOS/Linux/Windows workflows. Prevents `#212`-class breakage.
**`#214` Docs drift** — AGENTS/README/licenses vs code. Keep ROADMAP version = Cargo version (`#115`, `#152`).
---
### 2.10 Security (innerHTML presets)
**`#114` Preset manager XSS via `innerHTML`**
- **Root cause:** `renderManagePresetsList` interpolated `p.name` / `p.description` into `innerHTML`. Imported JSON is attacker-controlled. Tauri WebView is a privileged surface (`<img src=x onerror=…>` runs with backend IPC).
- **Fix (PR `#122`, with `#113`):** `createElement` + `textContent`. Optional Rust reject of `<` / control chars. **No** HTML sanitizer.
- **Invariant:** Untrusted strings (preset import, CGATS descriptors, chart labels, log excerpts) never go through `innerHTML`. `textContent` only.
---
### 2.11 Settings / presets / wizard chrome
**`#113` `applyPreset` never applies TIFF DPI**
- **Root cause:** Schema had `dpi`; Fast RGB Draft is 150. `applyPreset` skipped `#tiffDpi`; `collectCurrentSettingsAsPreset` hardcoded `dpi: 300`.
- **Fix:** Round-trip `#tiffDpi` (72600); show DPI in manage-presets subtitle.
- **Invariant:** Every `ProfilingPreset` field that exists on disk has a DOM binding in both directions. Adding a Stage 1/2/4 control without a preset field (or vice versa) is a bug. (v0.7.0 also broke the build by adding fields without updating the struct — `c917f05`.)
**`#60` Wizard is not a wizard**
- **Root cause:** Stepper allowed clicking Stage 5 with no files. Modules fell back to `"test_target"`.
- **Fix (v0.1.15 / v0.2.0):** Gate `.ti1``.ti2`+`.tif``.ti3``.icc`/`.icm`. Remove every `|| "test_target"`. Completed stages remain clickable backwards.
- **Invariant:** No placeholder basenames. Navigation = disk. `#151` re-validates artefacts on stage entry and app start (deleted files must re-lock).
**`#171` Tooltips reflow the layout**
- **Root cause:** Tooltip content inserted in-flow.
- **Fix (v0.6.2):** Absolute overlay on hover/focus; in-flow hints only when the global "show tooltip hints" toggle is on.
- **Invariant:** Hover must not change document flow.
**`#184` Configurable ΔE₀₀ thresholds** — settings persistence applied to the Stage 3 traffic lights. Same "settings must actually apply" rule as `#158`.
---
### 2.12 UI leftovers that still encode invariants
**`#178` Swatch grid:** honour `is_pad` (padding patches, `id == "0"`); render white patches; diagonal split intended vs measured; `printtarg` row/column order is canonical after bi-directional reverse. Device channels are 0100%; XYZ in JSON is 0100 and **must be /100** before `icmXYZ2Lab`.
**`#177` Buttons:** `.btn-sm/.btn-md/.btn-lg/.btn-icon-sq` only. No inline sizes. `#223` install button must use this hierarchy.
**`#140` Resume:** opening a `.ti2` jumps to Stage 3; `.ti1` to Stage 2. Parse header (instrument, patch count, colorants, pages). Do not regenerate `#163`-style random layouts over a chart that was already printed.
---
## 3. Open issues — implemented on main (`v0.8.5`)
Gitea still shows these as **open**. Git on `main` (merge `e12ef41`, tag `v0.8.5`) already contains the work. ROADMAP "Printer Calibration Release (`v0.8.5`)" matches.
| Ticket | State in tracker | Evidence on `main` | What shipped |
|---|---|---|---|
| **`#223` System-wide ICC/ICM install** | open | `980b44b` `feat(stage5): install generated ICC/ICM profiles into the OS (#223)` ; PR `#228` → development; PR `#229` → main | Stage 5 "Install Profile to System": copy into OS colour store (user or system), overwrite/rename/cancel, elevation guidance, note when printcal curves were applied |
| **`#224` printcal / applycal** | open | `24318bc` `feat(cal): printer linearization via printcal / applycal (#224)` ; PR `#227` → development; PR `#229` → main | Optional Stage 0: `CAL_` artefacts, Apply Calibration toggle → `printtarg -K` + `applycal`, channel-response plots, stale-cal warnings, project/library persistence |
Rewrite must treat Stage 0 calibration and Stage 5 install as **shipped** contracts, not backlog. Close the tickets when convenient.
---
## 4. Won't-fix
### `#90` Display wizard (`dispwin` / `dispread`) — Reviewed/Won't Fix
Asked for a second wizard mode: emissive monitor profiling (white point, gamma, cd/m², colorimeter loop, `dispwin -I` install).
**Why not:**
- ICCery's architecture, print engine, artefact chain (`.ti1``.tif``.ti3``.icc`), and raw-print colour-bypass work are **reflective printer** problems. Display calibration is a different product (full-screen test patches, VCGT, per-monitor ColorSync/ICC install).
- `dispwin`/`dispread` already *are* interactive GUIs; wrapping them duplicates Argyll's own display tools and DisplayCAL without sharing the print pipeline.
- Hardware, prompts, and OS install APIs do not reuse Stage 3's strip-reader state machine.
- Priority/Low, closed the same day it was filed during M8 triage. Printer-only scope is the product.
Do not quietly add a "Print vs Display" toggle in a rewrite. If display profiling happens, it is a separate app.
### `#96` i18n (en/de/fr/ja) — Reviewed/Won't Fix
ROADMAP: *Closed — Won't Fix (English UI retained as standard color-management terminology).*
**Why not:**
- The domain language **is** English: ΔE₀₀, cLUT, CGATS, Lab, FWA, OFPS, `targen -f`. Translating chrome without translating Argyll's English prompts (`Place the instrument on its reflective white reference…`) produces a mixed UI. Translating prompt matchers (`#137`, `#93`, `#175`) is how Stage 3 silently deadlocks.
- Argyll itself is English-only; `ARGYLL_NOT_INTERACTIVE` keystroke protocol is letter keys (`d`, `u`, `n`), not localized buttons.
- Cost of four complete dictionaries + locale-aware regex across every prompt classifier exceeded the value for a specialist tool.
Keep the UI English. Do not introduce `t()` until prompt classification is table-driven **and** Argyll grows locale support — which it will not.
---
## 5. Idiosyncrasies (do not rediscover)
These are the sharp edges issues burned in. A rewrite that "simplifies" any of them will re-open the ticket.
### 5.1 Argyll CLI is a minefield of one-letter flags
| Tool | Flag | Means | Ticket |
|---|---|---|---|
| `targen` | `-f N` | patch count; omit ⇒ **836** RGB | `#44` |
| `targen` | `-c file` | preconditioning **profile** (`.icc/.icm/.mpp`) | `#172` |
| `targen` | `-u` | JSON progress | argyll `#2` |
| `printtarg` | `-d "str"` | **custom label** on the chart | `#119` / argyll `#19` |
| `printtarg` | `-D` | 8-bit TIFF dither (different letter) | argyll `#19` |
| `printtarg` | `-R N` | PRNG seed; omit ⇒ non-deterministic layout | `#163` |
| `printtarg` | `-r` | raster order, no randomize | `#163` |
| `printtarg` | `-K` | apply calibration (Stage 0) | `#224` |
| `printtarg` | `-u` | JSON page manifest | `#68` / argyll `#3` |
| `chartread` | `-u` | `ROW_COLORS_JSON:` stream | `#5` / argyll `#1` |
| `chartread` | `-c N` | **comm port**, not instlist index | `#111` |
| `chartread` | `-d` | **display** read mode (not a directory, not a label) | argyll `#37` audit |
| `chartread` | `-Y l` | i1Pro2 LED (not `-L`) | `#204` |
| `chartread` | stdin `d` | **Done**, write `.ti3` | `#175` |
| `chartread` | stdin `u` | undo last strip | `#175` |
| `iccgamut` | `-d s` | **surface density** (use 10, not 50, not a path) | `#112` |
| `colprof` | `-d` | viewing condition | `#176` |
| `colprof` | `-c` | illuminant / FWA | `#176` |
| `colprof` | `-u` | JSON progress | argyll `#4` |
| `profcheck` | `-u` | JSON ΔE report | argyll `#5` |
| `instlist` | stdout JSON | `{event:"instruments", devices:[{port,name,type}]}` | `#134` / argyll `#6` |
**Same letter, four meanings of `-d`:** printtarg label, iccgamut density, chartread display mode, colprof viewing condition. Never name a helper `dash_d` without the tool.
### 5.2 Process / Windows / stdin
- Argyll binaries are **console** subsystem → `CREATE_NO_WINDOW` (`0x08000000`) (`#46`).
- `ARGYLL_NOT_INTERACTIVE=1` is mandatory or Windows ignores pipes (`#134`).
- Even then, Argyll's `SetNamedPipeHandleState(PIPE_NOWAIT)` **fails on anonymous pipes**; upstream `PeekNamedPipe` (argyll `#24`) is required or the instrument trigger never fires.
- `Child::wait()` drops stdin (`#84`). Take streams first; store stdin separately; kill via oneshot.
- Spawn ids are exclusive (`#116`).
- App exit must `kill_all` (`#147`, `#149`) or the spectro stays locked.
- Windows resolve: `targen` **and** `targen.exe` (`#85`).
- `tokio::process` + GDI print FFI: don't trust `windows` crate completeness (`#33`).
### 5.3 Files, extensions, artefacts
- `colprof` writes **`.icm` on Windows, `.icc` on Unix** (`#69`). Always probe both.
- Wizard chain: `.ti1``.ti2` + `.tif``.ti3``.icc`/`.icm` (+ `.gam`) (`#60`, `#151`).
- `{basename}.ti3` is the **accepted** measurement; passes live in `{basename}_passN.ti3` (`#109`, `#110`).
- Calibration artefacts use `CAL_` prefix and `.cal` (`#224`).
- TIFF is not a web image (`#58`). Convert in Rust.
- `.gam` = vertex table **plus** face table (`#179`).
- History JSON: atomic rename or you delete the user's drift archive (`#213`).
- Empty cwd is forbidden (`#59`).
### 5.4 Frontend / Tauri v2
- `window.__TAURI__.dialog` **does not exist**. Three tickets (`#103`, `#210`, `#211`). File dialogs are Rust commands with dedicated filters.
- Tauri v2 invoke args are **camelCase** (`passIndex`, not `pass_index`) (`#175`).
- JS payload field names must match Rust (`patch_count` vs `total_patches``#44`; `basename/cwd` vs `ti3_path/icc_path``#56`).
- Spawn id strings are part of the UI protocol (`colprof_${basename}`).
- Do not `innerHTML` untrusted preset/CGATS text (`#114`).
- Do not read `window` at module top-level if Node tests import the file (`#212`).
- Do not create WebGL on `DOMContentLoaded` (`#225`).
- Tooltips are overlays (`#171`).
### 5.5 Colour / print pipeline
- Raw print = unmanaged raster. Bypass **OS + driver + ICM** (`#25``#28`, `#36`, `#188`).
- Windows: full DEVMODE blob including `dmDriverExtra` (`#36`); `SetICMMode(ICM_OFF)` (`#33`).
- macOS: `AP_ApplicationColorMatching` + vendor keys (`CNIJIntent2`, Epson `ColorCorrection`); never crash-call 3-arg `PMSessionSetColorMatchingMode` (`#188`).
- PPD: send **choice codes**, display **titles**; vendor keywords differ (`InputSlot` / `EPIJ_FdSo` / `CNIJMediaSupply`; `MediaType` / `CNIJMediaType`) — CPU `#1`/`#2`.
- Hide CUPS widgets on Windows (`#48`).
- Default printtarg seed `-R 1` (`#163`).
- Device JSON is 0100%; XYZ 0100 must be divided by 100 before Lab (`ROW_COLORS_JSON` contract, argyll `#1`).
- Padding patches: `is_pad` / `id == "0"` (`#178`).
### 5.6 Packaging
- Linux: build on the oldest glibc you support (`#108` = Ubuntu 22.04).
- Strip foreign Argyll trees (`#52`).
- Fetch sidecars from `gronod/argyllcms` releases (`#127`); verify macOS ad-hoc signatures (`#165`).
- Manifest copy loops: strip CR (`\r`) or Windows zips silently omit USB drivers (argyll `#21`).
- Headless DMG: `dmgbuild`, not Finder AppleScript (`#189`).
- `minimumSystemVersion` is 12.0 after Monterey WebKit reality (`#225`), not 10.15.
### 5.7 Defaults people will hardcode wrong
- Patch count default if `-f` missing: **836**, not 0, not 1000 (`#44`).
- TIFF DPI built-in Standard = 300; Fast RGB Draft preset = **150** (`#113`).
- `iccgamut -d` = **10** (`#112`).
- Window 1280×800, min 1100×700 (`#61`).
- Log level is a setting, not `cfg!(debug_assertions)` (`#158`).
- LED flag is `-Y l` (`#204`).
---
## 6. Suggested rewrite test locks (from the tickets)
If the rewrite has tests, these are the ones the history says you will regret omitting:
1. `build_targen_args` includes `-f` from `patch_count` (`#44`).
2. `build_printtarg_args` includes `-R 1` (or explicit seed) (`#163`).
3. `build_iccgamut_args` is `-v -d 10 {profile}` — never a path, never 50 (`#112`).
4. `build_chartread_args` does not pass instlist ordinal as `-c` (`#111`).
5. Windows `resolve_binary("targen")` finds `targen.exe` (`#85`).
6. ProcessManager: duplicate id errors; stdin works during wait; kill_all on drop (`#84`, `#116`, `#149`).
7. Averaging: two snapshots, `average` argv unique files, Stage 4 locked until Finish (`#109`, `#110`).
8. Preset round-trip includes DPI; imported `<img onerror>` is text (`#113`, `#114`).
9. Profile resolver accepts `.icc` and `.icm` (`#69`).
10. `.gam` dual-table parse; profcheck JSON then regex fallback (`#179`).
11. History write is temp+rename (`#213`).
12. Node can `import` gamut/profcheck/chartread modules (`#212`, `#215`).
---
## 7. PR title index (page 2, remaining)
Page 2 of `pulls?state=all&limit=50&page=2` (older half of the genealogy):
`#144` Stage 1 advanced targen (`#141`) · `#143` resume `.ti2` (`#140`) · `#142` logging (`#139`) · `#138` Accept/Override (`#137`) · `#136`/`#135` v0.3.5 `ARGYLL_NOT_INTERACTIVE` (`#134`) · `#133` v0.3.4 · `#132` printtarg `-d` (`#119`) · `#131` v0.4.0 · `#130` macOS CUPS (`#92`) · `#129`/`#128` fetch-argyll (`#127`) · `#126` v0.3.3 · `#125` QuickHull module (`#117`) · `#124` docs v0.3.2 (`#115`) · `#123` duplicate spawn (`#116`) · `#122` preset DPI+XSS (`#113`,`#114`) · `#121` iccgamut density (`#112`) · `#120` comm port (`#111`) · `#118` averaging unique ti3 (`#109`,`#110`) · `#107` release asset naming · `#105`/`#104` Stage 1 browse (`#103`) · `#102` v0.3.0 · `#101` 3D hull (`#89`) · `#100` presets (`#88`) · `#99` averaging (`#87`) · `#98` instlist (`#86`) · `#97` `.exe` (`#85`) · `#83` process deadlock v0.2.1 (`#84`) · `#81` mermaid (`#80`) · `#79` print IPC consolidate (`#67`,`#68`) · `#77` wait/reap (`#63`) · `#75` wizard gating (`#60`) · `#74` gamut+extension (`#57`,`#69`) · `#73` TIFF PNG + window (`#58`,`#61`) · `#72` default cwd (`#59`) · `#64` colprof IPC (`#56`) · `#55` collapsible logs (`#54`) · `#53` strip foreign binaries (`#52`) · `#51` Stage 2 cleanup (`#50`) · `#49` hide CUPS on Windows (`#48`) · `#47` `CREATE_NO_WINDOW` (`#46`) · `#45` targen `-f` (`#44`) · `#43` installer art (`#42`) · `#41` Linux CI (`#40`)
Recent page-1 merges of note: `#229` v0.8.5 (`#223`+`#224`) · `#228` install · `#227` printcal · `#226` Monterey (`#225`) · `#222` dmgbuild (`#189`).

Some files were not shown because too many files have changed in this diff Show More